Nimbu Developer Docs

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:

  1. Sign: ask Nimbu for an upload URL for one file and one field.
  2. Upload: PUT the bytes straight to storage with the headers Nimbu returned.
  3. Attach: send the returned FileRef as 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.

UseWhen
Direct uploadFiles above a few hundred KB, browser uploads, anything a user picks from disk
Base64Small generated files (a QR code, a signature image) or scripts where one request is simpler

Direct uploads work for:

  • custom fields of type file or gallery on 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" }
  }'
KeyRequiredDescription
purposeyesfile_field
filenameyesFile name with extension. The extension must be allowed by the target field
sizeyesExact size in bytes. It is signed into the upload, so storage refuses any other size
content_typenoThe 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.typeyeschannel_entry, customer or product
target.channelfor entriesChannel slug or id
target.fieldyesName of a file or gallery custom field, or images for product images
target.idnoThe 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
}
  • upload is valid for 15 minutes (expires_in is in seconds). Sign right before you upload.
  • file is the value you send in step 3. It stays usable for 24 hours (file.expires_at).
  • max_file_size is the largest file this field takes from you, in bytes: the field's own max_file_size when 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.pdf

Don'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 for bijlage can't go into foto, and a gallery upload can't go into a file field.
  • Record:
    • without target.id the FileRef only works when creating a record (POST)
    • with target.id it only works when updating that record
  • 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

CredentialChannel entriesCustomersProducts
Backend user, site token, master tokenwrite_channels scopewrite_customers scopewrite_products scope
Customer token, app token with a customer session, anonymous tokenwhere enabled, see belowwhere enabled, see belowno

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:

  1. 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" }
  2. The field allows it. The target field has storefront_direct_uploads turned 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 a write_protected customer field, which visitors can't write. Otherwise the answer is 403 Direct uploads from visitors are not enabled for this field. Product images have no such option, so storefront credentials can't sign for them.
  3. 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:

LevelWhenEffect
RedUpdate 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
OrangeCreate 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
OKAnything elseAllowed

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_size from the sign response (the field's limit, at most 50 MB). Declare the size in size; the upload can't be larger than what you signed. A larger size is refused with 422 File is too large: size exceeds the maximum of 10485760 bytes.
  • Extensions: the field's allowed_file_types, and for storefront credentials no svg or other files unless the field lists them (see Storefront defaults). A refused type answers 422, for example File 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 as photo.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:

StatusWhen
401No credential, or a token whose grant or customer no longer exists. Also a customer update the ACL refuses, as PATCH /customers/{id} answers
403Missing 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)
404Channel or record not found, or the channel ACL doesn't let you create or update it
422Missing 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
429Rate limit or unused-upload cap reached

From the write that uses the FileRef:

StatusWhen
403A 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
404Temporary upload not found (wrong source) or Temporary upload object not found (the PUT never happened)
410Temporary upload is no longer available: it expired or a write already used it
422Temporary 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)
502Copying the file in storage failed. Retry the write

The write route's own errors (validation, ACL) apply as usual.

On this page