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 largest file this field takes from you, in bytes: the field's ownmax_file_sizewhen it has one, otherwise 50 MB. Storefront credentials never get more than 50 MB. Use it to check a file in the browser before you upload it.
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. A gallery
field's allowed_file_types can narrow that down further.
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 (the customer field cv needs storefront_direct_uploads, see
Storefront credentials):
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 can only sign when all of these hold:
- The write itself is allowed. 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" }
- The field allows it. The target field has
storefront_direct_uploadsturned on (see Custom Field Types). It can be turned on for file and gallery fields of channel entries and file fields of customers, but not on awrite_protectedcustomer field, which visitors can't write. Otherwise the answer is403 Direct uploads from visitors are not enabled for this field. Product images have no such option, so storefront credentials can't sign for them. - The ACL is safe for it. The ACL of the records that hold the field (the channel's ACL for entry fields, the customer ACL for customer fields) must not let other people replace or remove files. See the table below.
The ACL levels:
| Level | When | Effect |
|---|---|---|
| Red | Update or Delete is Open (public) or Shared (shared, any signed-in customer) | The option can't be turned on. Signing is refused with 403, also when the ACL was opened up after the option was turned on |
| Orange | Create and Read are both Open, and the field doesn't use Block Public Access (private_storage) | Allowed. The field editor warns that the field works as public file hosting and suggests private storage |
| OK | Anything else | Allowed |
A red refusal is a 403 that names the actions that are too open, for example:
{
"message": "Visitors could replace or remove files someone else uploaded: Manage Access of this channel's entries allows Update: Shared (only signed-in users). Set Update and Delete to Private or Not allowed first."
}The write that uses the FileRef checks the option and the ACL again. Turning the option off or
opening up the ACL also stops FileRefs that were signed before; the write answers 403 with the
same message.
Customer gallery fields don't take direct uploads (422) and can't turn the option on. 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
max_file_sizefrom the sign response (the field's limit, at most 50 MB). Declare the size insize; the upload can't be larger than what you signed. A largersizeis refused with422 File is too large: size exceeds the maximum of 10485760 bytes. - Extensions: the field's
allowed_file_types, and for storefront credentials nosvgorotherfiles unless the field lists them (see Storefront defaults). A refused type answers422, for exampleFile extension "jpg" is not allowed for this field (allowed: pdf, spreadsheets), at sign time and again on the write. 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. For storefront credentials also: the field doesn't have storefront_direct_uploads or is write protected, or the owner's ACL is red (see Storefront credentials) |
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, a customer gallery field, missing or invalid size, file larger than max_file_size, extension not allowed by the field, or an invalid or HTML content_type |
429 | Rate limit or unused-upload cap reached |
From the write that uses the FileRef:
| Status | When |
|---|---|
403 | A FileRef from another site, a storefront credential sent a customer write a FileRef that isn't a signed upload, or (storefront credentials) the field no longer has storefront_direct_uploads or its owner's ACL turned red since signing |
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, of a type the field doesn't allow, doesn't match its type, or changed while Nimbu checked it (Temporary upload changed during verification: sign and upload again) |
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, and how file fields limit uploads.
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.