BuildBaseBuildBase

Overview

Upload images, store them public or private, read back their URLs and dimensions, delete them, and see how much storage your organization holds.

Assets are the images your content embeds: logos, thumbnails, pictures in posts and email templates. Upload one in a single multipart request up to 5 MB, or direct to storage up to 100 MB. Whether the object is publicly readable is decided at upload time, and can be changed later.

curl -X POST https://api.console.buildbase.app/api/assets \
  -H "Authorization: $BUILDBASE_TOKEN" \
  -F "[email protected]" \
  -F "public=true"

Before you start

Send the file under the field name file, one per request. public is read as the string "true" - a JSON boolean in a multipart form will not register, and the asset lands private.

Limits

ConstraintMultipart (POST /api/assets)Direct upload (below)
Maximum file size5 MB100 MB (SVG: 5 MB)
Your planits asset size limit, if lowerits asset size limit, if lower
Files per request11
Accepted typesJPEG, PNG, GIF, WebP and SVGJPEG, PNG, GIF, WebP and SVG

A multipart request over 5 MB is refused with 413 and code UPLOAD_TOO_LARGE before its body is read, so nothing of it is stored. For anything larger, upload direct. Your plan can set a lower asset size limit, and an upload over it is refused with the plan-limit error rather than stored.

The file's bytes decide its type, not the Content-Type the request sends. The declared type has to be one of the five above, and the contents have to match a supported image format - anything else gets a 400. The detected type is what lands in mimeType, so an HTML file labelled image/png is refused rather than stored as a PNG.

SVGs are sanitised before storage: scripts, event handlers and javascript: URLs are stripped, and the stored file is the cleaned one, not the one you sent. An SVG with nothing left to render after cleaning is refused.

Uploading direct

For files over 5 MB, or to keep large bodies off your own servers, the file goes straight to storage on a signed URL. Three requests:

# 1. Declare it. Nothing is sent yet, so a file that cannot be stored is
#    refused here - too large, not an image, over your storage.
curl -X POST https://api.console.buildbase.app/api/assets/uploads \
  -H "Authorization: $BUILDBASE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"diagram.png","size":2400000,"contentType":"image/png","public":true}'

# 2. PUT the bytes to the URL it returned, with exactly the headers it returned.
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  -H "x-goog-content-length-range: 0,2400000" \
  -H "x-goog-if-generation-match: 0" \
  -H "x-goog-meta-org-id: $ORG_ID" \
  -H "x-goog-meta-upload-id: $UPLOAD_ID" \
  --data-binary @diagram.png

# 3. Complete it. The answer is the asset.
curl -X POST https://api.console.buildbase.app/api/assets/uploads/$UPLOAD_ID/complete \
  -H "Authorization: $BUILDBASE_TOKEN"

The answer to the first request:

FieldContents
modedirect, or multipart when direct upload is not available
uploadIdWhat to complete
urlThe signed URL to send the file to, valid for 15 minutes
methodThe HTTP method for url: PUT
headersEvery header the PUT must carry - they are signed
expiresAtWhen the URL stops working
maxBytesThe size you declared, which is the most the PUT can carry

The first request takes name, size in bytes, contentType and public; a field it does not know is refused with 400. Send the headers exactly as returned: they are part of the signature, and a PUT with any of them changed is refused. Storage refuses a body larger than the size you declared, and refuses to overwrite.

complete checks what arrived, not what was declared: the size is what storage holds, the type is what the bytes are, and an SVG is sanitised. It is safe to call twice - the second call answers the same asset. If you never call it, the upload is still completed - as soon as storage reports the file, or about an hour later at the latest - so a client that dies after its PUT still gets its asset. An upload that is declared and never sent is let go after an hour.

StatusCodeWhen
400INVALID_UPLOADWhat arrived is empty, larger than declared, or not a supported image. The file is removed and nothing is kept
404No upload of your organization has this id, or it was already let go
409UPLOAD_NOT_RECEIVEDThe PUT has not finished. Complete it once the PUT answers
409UPLOAD_FINALIZINGAnother call is completing the same upload right now. Try again in a moment

When mode is multipart, the answer carries only maxBytes (5 MB): send the file to POST /api/assets as above.

What is stored

FieldContents
nameThe original filename
uniqueNameThe generated storage name
mimeTypeDetected content type
sizeBytes
encodingTransfer encoding
bucket{ name, url, path } - where the object lives
publicWhether the object URL is publicly readable
image{ width, height }
metadataEmpty object, reserved
creatorThe uploading user

image dimensions are read from the file. When they cannot be read - most often an SVG with no width and height attributes - both values are stored as 0. A 0 × 0 asset uploaded fine; it just has no intrinsic size on record.

Warning

Files are stored as uploaded. There is no resizing, format conversion, or thumbnail generation, and the URL is a plain object-storage URL with no transform parameters. Requesting a smaller version by appending query parameters does nothing.

If you need responsive images, generate the variants before uploading and store them as separate assets.

Public and private

Visibility is set at upload and can be changed afterwards:

MethodPathEffect
PATCH/api/assets/:id/publicMake the object publicly readable
PATCH/api/assets/:id/privateRestrict it again

The stored URL stays the same in both states; only who may read it changes. Reading a private asset through the API returns a signed URL instead, valid for 15 minutes. Making an asset private means its stored URL stops answering anyone without storage access, so anything already embedding that URL breaks - check where it is used first.

Public means public: the object URL is unguessable but unauthenticated. Do not use it for anything you would gate behind a permission.

Reading

MethodPathPurpose
GET/api/assetsList assets, paginated
GET/api/assets/:idOne asset
GET/api/assets/:id/usageWhere it is used

Filter with the standard parameters - by MIME type, for example:

curl -G https://api.console.buildbase.app/api/assets \
  -H "Authorization: $BUILDBASE_TOKEN" \
  --data-urlencode 'filter={"public":true}' \
  --data-urlencode 'sort={"createdAt":-1}'

Where an asset is used

Content stores the asset's URL, not the asset record, so two changes break an embed: making the asset private, and deleting it. Before either, ask where it is used:

curl https://api.console.buildbase.app/api/assets/$ASSET_ID/usage \
  -H "Authorization: $BUILDBASE_TOKEN"
{
  "uses": [
    {
      "kind": "Blog post",
      "title": "Launch notes",
      "href": "/dashboard/admin/blogs/6ace..."
    },
    {
      "kind": "Testimonial",
      "title": "Ada Lovelace",
      "href": "/dashboard/admin/testimonials"
    }
  ],
  "truncated": false
}

Each use has a kind, the title or name of the thing using the asset, and href, where it is edited in the console, or null when it has no page there. The console's Delete and Make private confirmations show the same list before you confirm.

It looks in every place the console and the SDK put an image: blog posts and docs (cover and body), blog and doc authors, FAQs, rich content, email templates, collection records and form submissions (file fields and rich text), collection field defaults, testimonials, the organization icon, the sign-in page and waitlist logos, push campaigns, workspace images and your app's users' pictures. Text is matched on the asset's generated storage name, which every URL to it ends with, so a URL written through a CDN or your own domain is found too.

At most 10 uses are listed from each place; truncated is true when one place had more. A link copied anywhere else - another site, an email already sent - is not found and still breaks.

Deleting

MethodPathEffect
GET/api/assets/:id/usageWhere it is used, before you delete it
DELETE/api/assets/:idRemove the stored file, then the asset

The file is removed from storage first and the asset record only after storage confirms it. If storage cannot remove the file, the request answers 502 and the asset is kept, so you can try again rather than lose track of a file that is still stored. Deleting frees the space at once.

Image URLs in other fields

Fields that take an image as free text - a workspace image, a user's profile picture, blog and doc author images, testimonial avatars and company logos, push campaign icons, the waitlist logo - take a URL of at most 2048 characters. A longer value is refused with 400. A data: image is not held to the length; its size is checked by the inline file rule instead.

Storage

Every byte your organization stores is counted, on every plan. GET /api/storage/usage answers with what it holds now:

FieldContents
usedBytesBytes of every stored file
objectCountHow many files
allowanceBytesWhat your plan includes, or null when it sets no number
ceilingBytesThe most you can hold before uploads are refused, or null
causeWhy the ceiling is where it is: plan, trial or payment, or null when there is no ceiling
reservedBytesBytes of uploads in flight, counted against the ceiling
unlimitedtrue when your plan's storage has no limit
allowOveragetrue when your plan bills storage past the allowance instead of stopping at it
enforcedtrue when uploads past the ceiling are refused
hardStoptrue when you stopped uploads at your allowance yourself
reconciledAtWhen the count was last checked against your stored files, or null before the first check
endUserUploadsWhat your app's users may upload: { enabled, maxFileBytes, perUserBytes } (below)
billingThis usage period's storage billing, or null (below)
bandwidthWhat your files were served this month, or null (below)

A gigabyte here is 2^30 bytes (1,073,741,824), the unit your plan's storage allowance is measured in. The same numbers appear beside the upload button in the console and on the billing page.

How storage is billed

Storage is billed in GB-months: gigabytes held, weighted by how long they were held during your usage period. 10 GB held for the whole period is 10 GB-months; 20 GB held for half of it is also 10. Your plan's storage allowance is in the same unit, so a plan that includes 10 GB includes 10 GB held all month, and only what is held beyond it is billed as overage.

It is measured from the bytes your organization holds, continuously - not sampled once a month, and not the bytes uploaded. Deleting a file stops it counting from that moment. Whole GB-months are counted as they accrue; the fraction under one GB-month left at the end of a period is not billed.

billing in the usage response shows the period so far. It is null when storage is not billed for your organization, as on a self-hosted install, and before the first hourly count:

FieldContents
modeshadow while storage billing is not on, enforce once it is
periodStartWhen this usage period began
periodEndWhen it ends and the count starts again
gbMonthsAccrued so far this period
reportedUnitsWhole GB-months billed so far this period, or in shadow, that would be

In shadow, nothing is charged: the figures are what would be billed, so you can compare them with your own expectations first. The billing page says "Storage billing is not on yet" beside them.

Turning overage off

On a plan that bills storage overage, uploads continue past your allowance - billed - up to five times it. To stop at the allowance instead, turn overage off on the billing page, or send PATCH /api/storage/settings with {"hardStop": true}. Uploads past the allowance are then refused and nothing is billed over it. It takes the permission to change organization settings.

Bandwidth

Public files are served straight from storage. bandwidth in the usage response is what they were served this calendar month (UTC), measured from storage's own request logs: bytes, requests, and since, the first day counted. It is measured, not billed - serving your files is fair use, and very high volumes may be asked to move to a custom plan. It is null where it is not measured.

When an upload does not fit

Each upload reserves its size against ceilingBytes before its body is read. When enforced is true and the upload would take your organization past the ceiling, it is refused with 402 and nothing is stored:

{
  "success": false,
  "code": "PLAN_LIMIT_REACHED",
  "quotaSlug": "storage-gb",
  "message": "This 42 KB upload does not fit: 71.5 KB of the 100 KB your plan allows is already stored. Delete files you no longer need, or move to a plan with more storage.",
  "storage": {
    "usedBytes": 73208,
    "reservedBytes": 0,
    "ceilingBytes": 102400,
    "wantedBytes": 43002,
    "cause": "plan"
  }
}

Storage is held, not spent: deleting files makes room straight away. cause says where the ceiling comes from - plan for your plan's storage, trial while a trial is capped at 1 GB, and payment while a payment is failing, when no new files are taken until it is fixed. Nothing stored is removed in any of these cases.

wantedBytes is the size of the whole request, which is a little more than the file itself, so an upload that would land exactly on the ceiling can be refused.

Files belong in assets

A file pasted into a JSON field as a data: URI is stored outside assets and counted nowhere. When enforced is true, a request carrying one over 16 KB is refused with 413 and code INLINE_FILE_TOO_LARGE, naming the field - on the console API and on the SDK's API alike, so a workspace image sent as a data: URI is refused too. Upload the file and use its URL instead. Small inline images - an icon, an emoji - are left alone.

File lifecycle

  • Warnings. Your organization's owner is emailed once when storage reaches 80% of what it may hold, once when it goes past the plan's allowance, and once when uploads are about to stop at the ceiling. They are sent only while enforced is true.
  • A full ceiling deletes nothing. Reaching it, a trial cap or a failing payment stops new uploads; stored files stay and keep serving.
  • A lapsed subscription deletes nothing. Removing the files of an organization that has gone 30 days without a subscription is built but not switched on.

Every upload counts

Storage is your organization's, whoever uploads into it:

WhoHowStored as
You and your teamThe console, POST /api/assets or POST /api/assets/uploadsPublic or private
Your app's usersThe SDK's useUpload - a workspace image or a profile picturePublic, the user as creator
Your forms' visitorsA form's file fieldPrivate

All three go through the same checks before a byte is sent and appear in your assets.

Uploads by your app's users

The SDK's useUpload hook lets a signed-in user of your app upload a workspace image or a profile picture. Their files are yours: stored for your organization, counted against its storage, public so they can be shown, and kept until you delete them in Assets.

Note

Uploads need @buildbase/sdk 0.0.84 or later. An older SDK has no upload control: its workspace image is an emoji or a URL.

Choosing what they may upload

Set it under Settings > Billing, in the End-user uploads part of the Storage card, or with PATCH /api/storage/settings:

curl -X PATCH https://api.console.buildbase.app/api/storage/settings \
  -H "Authorization: $BUILDBASE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"endUserUploads":{"enabled":true,"maxFileBytes":1048576,"perUserBytes":null}}'
FieldContentsDefault
enabledWhether your app's users may upload at alltrue
maxFileBytesThe largest one file may be, from 102,400 (100 KB) to 5,242,880 (5 MB)5 MB
perUserBytesThe most one user may hold in all, at least 100 KB, or null for no per-user limitnull

Send any of the three; the others keep their values. It takes the permission to change organization settings, and answers the usage response with the new endUserUploads. The SDK reads the policy from your public settings, which GET /api/v1/public/:orgId/settings answers as uploads: { enabled, maxFileBytes }. enabled there is also false when the server has no direct uploads, so the SDK offers an upload only when one can succeed.

The requests

useUpload does all of this; the routes matter when you build your own. Each runs with the signed-in user's session, like every SDK call.

MethodPathBody or answer
POST/api/v1/public/uploads{ purpose, name, size, contentType }, answered like an asset intent: uploadId, url, method, headers, expiresAt, maxBytes
PUTthe signed urlThe file, with exactly the headers returned
POST/api/v1/public/uploads/:id/complete{ id, name, size, mimeType, url, width, height } - url is the file's public URL

purpose is workspace-image or profile-image, and the intent takes no other field. Only the user who started an upload can complete it; anyone else gets 404.

StatusCodeWhen
400INVALID_UPLOADA purpose other than the two above, a missing size, a type that is not an image, or a file that is not what it was declared as
403UPLOADS_DISABLEDYou turned end-user uploads off
403USER_UPLOAD_LIMITThe file would take the user past perUserBytes; the body carries heldBytes and limitBytes
413UPLOAD_TOO_LARGEThe file is over maxFileBytes; the body carries maxBytes
429Too many uploads from one address, or for your organization today
503UPLOADS_UNAVAILABLEYour organization cannot take the file: storage full, or a plan limit
501UPLOADS_UNAVAILABLEThe server has no direct uploads configured
409UPLOAD_NOT_RECEIVED, UPLOAD_FINALIZINGOn complete: the PUT has not arrived, or the file is still being checked. Try again in a moment

The 503 is deliberately neutral: your user cannot act on your plan, and your storage meter already says why. The 402 above is never sent to them.

Saving the result

The upload answers a URL; store it where the image belongs:

  • A workspace image: send it as image when creating or updating the workspace (PUT /api/v1/public/workspaces/:id with name and image).
  • A profile picture: PATCH /api/v1/public/profile with { "image": url }. An empty string removes the picture.

Both fields take any image URL up to 2048 characters, not only an uploaded one.

Next Steps