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 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.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.

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:

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 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 in size; 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 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, or a storefront credential where storefront uploads are off
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, 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
429Rate limit or unused-upload cap reached

From the write that uses the FileRef:

StatusWhen
403A FileRef from another site, or a storefront credential sent a customer write a FileRef that isn't a signed upload
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 or doesn't match its type
502Copying the file in storage failed. Retry the write

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

On this page