Direct File Uploads
Upload files for channel entry, customer and product file fields straight to storage with a signed URL, then attach them with a FileRef instead of sending base64.
A file field normally takes its file as base64 inside the JSON write (see Custom Field Types). That is fine for a thumbnail. For a 30 MB PDF it means a JSON body of 40 MB that the API has to receive and decode before it can check anything else in the write.
Direct uploads split that work in three steps:
- Sign: ask Nimbu for an upload URL for one file and one field.
- Upload:
PUTthe bytes straight to storage with the headers Nimbu returned. - Attach: send the returned
FileRefas the field value when you create or update the record.
Nimbu checks and copies the file during step 3. The write either stores the file or fails without changing the record.
| Use | When |
|---|---|
| Direct upload | Files above a few hundred KB, browser uploads, anything a user picks from disk |
| Base64 | Small generated files (a QR code, a signature image) or scripts where one request is simpler |
Direct uploads work for:
- custom fields of type
fileorgalleryon channel entries, customers and products - the built-in product images (
images)
Pages, blogs and other resources don't take them yet. Keep using base64 there.
1. Sign the upload
POST /uploads/sign with purpose: "file_field":
curl -X POST "https://api.nimbu.io/uploads/sign" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Nimbu-Site: $SITE_ID" \
-H "Content-Type: application/json" \
-d '{
"purpose": "file_field",
"filename": "offerte.pdf",
"content_type": "application/pdf",
"size": 1834221,
"target": { "type": "channel_entry", "channel": "aanvragen", "field": "bijlage" }
}'| Key | Required | Description |
|---|---|---|
purpose | yes | file_field |
filename | yes | File name with extension. The extension must be allowed by the target field |
size | yes | Exact size in bytes. It is signed into the upload, so storage refuses any other size |
content_type | no | The type you will send with the PUT, e.g. a browser's file.type. Omit it and Nimbu derives one from the filename. HTML types are refused |
target.type | yes | channel_entry, customer or product |
target.channel | for entries | Channel slug or id |
target.field | yes | Name of a file or gallery custom field, or images for product images |
target.id | no | The record you will update. Omit it when the file is for a new record (see Binding) |
The response (201):
{
"upload": {
"method": "PUT",
"url": "https://storage.example.com/s/ab12cd/tmp/6f1c…/offerte.pdf?X-Amz-Algorithm=…",
"headers": {
"Content-Type": "application/pdf",
"Content-Length": "1834221",
"x-amz-acl": "private"
},
"expires_in": 900,
"expires_at": "2026-10-15T10:15:00Z"
},
"file": {
"__type": "FileRef",
"source": "nimbu://ab12cd/tmp/6f1c…/offerte.pdf",
"expires_at": "2026-10-16T10:00:00Z"
},
"max_file_size": 52428800
}uploadis valid for 15 minutes (expires_inis in seconds). Sign right before you upload.fileis the value you send in step 3. It stays usable for 24 hours (file.expires_at).max_file_sizeis the field's limit in bytes, currently 50 MB.
2. Upload the file
Send the raw bytes to upload.url with exactly the headers in upload.headers. They are part of
the signature: a different Content-Type, a missing x-amz-acl or a body of another size makes
storage answer 403.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
-H "Content-Length: 1834221" \
-H "x-amz-acl: private" \
--data-binary @offerte.pdfDon't send your Nimbu Authorization header to storage. The URL carries its own signature.
In a browser, pass the returned headers through as they are. fetch won't let you set
Content-Length and computes it from the body, which matches when you upload the same File you
signed for:
await fetch(upload.url, { method: upload.method, headers: upload.headers, body: file });3. Attach the file
Send the file object as the field value in the write the upload was signed for. You can send it
exactly as returned; expires_at is ignored on writes.
curl -X POST "https://api.nimbu.io/channels/aanvragen/entries" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Nimbu-Site: $SITE_ID" \
-H "Content-Type: application/json" \
-d '{
"title": "Offerte Janssens",
"bijlage": { "__type": "FileRef", "source": "nimbu://ab12cd/tmp/6f1c…/offerte.pdf" }
}'During the write Nimbu checks the stored object (it exists, isn't empty, fits the field, and its contents match its type), copies it into the record's storage and removes the temporary copy. The response shows the field like any other file field.
If the write fails (a validation error, another field that fails), the copy is rolled back and the
FileRef stays usable until it expires. Fix the payload and send it again. Once a write has used a
FileRef, it is gone: sending it again returns 410.
Galleries and product images
Sign one upload per image, with target.field set to the gallery (or images). This works
for channel entry and product galleries; customer gallery fields don't take direct uploads. Then put
each FileRef in the file key of an image:
{
"fotos": {
"__type": "Gallery",
"images": [
{
"__type": "GalleryImage",
"file": { "__type": "FileRef", "source": "nimbu://ab12cd/tmp/0a9e…/gevel.jpg" },
"caption": "Gevel",
"position": 0
},
{
"__type": "GalleryImage",
"file": { "__type": "FileRef", "source": "nimbu://ab12cd/tmp/3b7d…/tuin.jpg" },
"caption": "Tuin",
"position": 1
}
]
}
}The plain array form works too, including for product images:
{
"images": [
{ "file": { "__type": "FileRef", "source": "nimbu://ab12cd/tmp/0a9e…/packshot.png" } }
]
}Gallery and product images accept jpg, jpeg, gif, png, bmp, svg and webp.
Binding
Each upload is tied to what you signed it for. The write that uses the FileRef must match on all
of these, or it fails with 422 Temporary upload was signed for a different target:
- Field: the same
target.field, on the same channel for entries. A file signed forbijlagecan't go intofoto, and a gallery upload can't go into a file field. - Record:
- without
target.idtheFileRefonly works when creating a record (POST) - with
target.idit only works when updating that record
- without
- Credential: the same user, app installation or customer that signed it. For anonymous tokens this is the same OAuth application.
For customers, target.id can be "current": the customer the request acts for. Pair it with
PATCH /customers/current.
Signing is checked like the write it prepares. If your credential can't create entries in a channel, it can't sign for that channel either, and the write checks permissions again when it runs.
Example in JavaScript
With the Nimbu JS SDK, Nimbu.Uploads.upload takes care of steps 1 and 2: it signs, PUTs the file with the signed headers and resolves with the FileRef. Here a customer signs up with a CV:
const cv = await Nimbu.Uploads.upload(input.files[0], {
target: { type: 'customer', field: 'cv' },
});
await Nimbu.Customer.signUp({ email: 'an.janssens@example.com', password: secret, cv });On an SDK version without Nimbu.Uploads, sign and PUT yourself:
const { upload, file: cv } = await Nimbu.API.post('/uploads/sign', {
purpose: 'file_field',
filename: file.name,
content_type: file.type || undefined,
size: file.size,
target: { type: 'customer', field: 'cv' },
});
const put = await fetch(upload.url, { method: upload.method, headers: upload.headers, body: file });
if (!put.ok) throw new Error(`Upload failed with ${put.status}`);Who can sign
| Credential | Channel entries | Customers | Products |
|---|---|---|---|
| Backend user, site token, master token | write_channels scope | write_customers scope | write_products scope |
| Customer token, app token with a customer session, anonymous token | where enabled, see below | where enabled, see below | no |
Sensitive channels also need write_sensitive_channels, as they do for writes.
Storefront credentials
Customer tokens, app tokens sent with a customer session and anonymous tokens from the JS SDK sign
only where Nimbu has turned on storefront direct uploads. Until then they get
403 Signed file field uploads are not available for this credential yet; base64 keeps working for
them as before.
Where it is on, they need the same scopes as for the write (write_channels, write_customers),
and the channel and customer ACLs decide, exactly as they do for the write:
- a new entry: when the channel's create ACL lets the customer or visitor create one
- an existing entry: when the channel's update ACL lets them update it
- a new customer: a sign-up form with an anonymous token
- their own profile:
target: { "type": "customer", "field": "cv", "id": "current" }
See Channel Access Control for the ACL rules. Storefront credentials
can't send any other kind of FileRef to customer fields: only uploads signed this way.
Limits
- Size: up to the field's
max_file_size(50 MB). Declare it insize; the upload can't be larger than what you signed. - Extensions: the field's own rules. HTML files (
html,htm,xhtml, …) are always refused, and so is HTML or XML markup uploaded under another file type (for example a page saved asphoto.jpg). Other contents aren't checked against the extension, so validate files yourself when that matters. - Rate: storefront credentials can make 30 sign requests per minute. Anonymous tokens count per application and client network. Backend users and site tokens aren't limited here.
- Unused uploads: signed uploads that no write has used yet are capped at 20 per storefront credential and 200 per backend credential. Storefront credentials also share a cap of 1,000 per site; backend credentials don't count towards it. An unused upload stops counting an hour after its upload URL expires, so the slots free up again on their own.
Both limits answer 429.
Errors
From POST /uploads/sign:
| Status | When |
|---|---|
401 | No credential, or a token whose grant or customer no longer exists. Also a customer update the ACL refuses, as PATCH /customers/{id} answers |
403 | Missing scope, or a storefront credential where storefront uploads are off |
404 | Channel or record not found, or the channel ACL doesn't let you create or update it |
422 | Missing or invalid target, unknown field or not a file/gallery field, missing or invalid size, file larger than max_file_size, extension not allowed, invalid or HTML content_type, or the site's storage doesn't support direct uploads |
429 | Rate limit or unused-upload cap reached |
From the write that uses the FileRef:
| Status | When |
|---|---|
403 | A FileRef from another site, or a storefront credential sent a customer write a FileRef that isn't a signed upload |
404 | Temporary upload not found (wrong source) or Temporary upload object not found (the PUT never happened) |
410 | Temporary upload is no longer available: it expired or a write already used it |
422 | Temporary upload was signed for a different target (see Binding), or the stored file is empty, too large or doesn't match its type |
502 | Copying the file in storage failed. Retry the write |
The write route's own errors (validation, ACL) apply as usual.
Custom Field Types
Learn how to send and receive file uploads, references, galleries, and other complex field types when working with channel entries via the Nimbu REST API.
Working with Pages
Read and safely change page content through the REST API: the page document, merge vs replace updates, surgical batch operations with ETags and If-Match, dry runs, compact reads, drafts and preview links, schema and item reads, and page versions.