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
| Constraint | Multipart (POST /api/assets) | Direct upload (below) |
|---|---|---|
| Maximum file size | 5 MB | 100 MB (SVG: 5 MB) |
| Your plan | its asset size limit, if lower | its asset size limit, if lower |
| Files per request | 1 | 1 |
| Accepted types | JPEG, PNG, GIF, WebP and SVG | JPEG, 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:
| Field | Contents |
|---|---|
mode | direct, or multipart when direct upload is not available |
uploadId | What to complete |
url | The signed URL to send the file to, valid for 15 minutes |
method | The HTTP method for url: PUT |
headers | Every header the PUT must carry - they are signed |
expiresAt | When the URL stops working |
maxBytes | The 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.
| Status | Code | When |
|---|---|---|
400 | INVALID_UPLOAD | What arrived is empty, larger than declared, or not a supported image. The file is removed and nothing is kept |
404 | No upload of your organization has this id, or it was already let go | |
409 | UPLOAD_NOT_RECEIVED | The PUT has not finished. Complete it once the PUT answers |
409 | UPLOAD_FINALIZING | Another 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
| Field | Contents |
|---|---|
name | The original filename |
uniqueName | The generated storage name |
mimeType | Detected content type |
size | Bytes |
encoding | Transfer encoding |
bucket | { name, url, path } - where the object lives |
public | Whether the object URL is publicly readable |
image | { width, height } |
metadata | Empty object, reserved |
creator | The 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:
| Method | Path | Effect |
|---|---|---|
PATCH | /api/assets/:id/public | Make the object publicly readable |
PATCH | /api/assets/:id/private | Restrict 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
| Method | Path | Purpose |
|---|---|---|
GET | /api/assets | List assets, paginated |
GET | /api/assets/:id | One asset |
GET | /api/assets/:id/usage | Where 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
| Method | Path | Effect |
|---|---|---|
GET | /api/assets/:id/usage | Where it is used, before you delete it |
DELETE | /api/assets/:id | Remove 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:
| Field | Contents |
|---|---|
usedBytes | Bytes of every stored file |
objectCount | How many files |
allowanceBytes | What your plan includes, or null when it sets no number |
ceilingBytes | The most you can hold before uploads are refused, or null |
cause | Why the ceiling is where it is: plan, trial or payment, or null when there is no ceiling |
reservedBytes | Bytes of uploads in flight, counted against the ceiling |
unlimited | true when your plan's storage has no limit |
allowOverage | true when your plan bills storage past the allowance instead of stopping at it |
enforced | true when uploads past the ceiling are refused |
hardStop | true when you stopped uploads at your allowance yourself |
reconciledAt | When the count was last checked against your stored files, or null before the first check |
endUserUploads | What your app's users may upload: { enabled, maxFileBytes, perUserBytes } (below) |
billing | This usage period's storage billing, or null (below) |
bandwidth | What 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:
| Field | Contents |
|---|---|
mode | shadow while storage billing is not on, enforce once it is |
periodStart | When this usage period began |
periodEnd | When it ends and the count starts again |
gbMonths | Accrued so far this period |
reportedUnits | Whole 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
enforcedistrue. - 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:
| Who | How | Stored as |
|---|---|---|
| You and your team | The console, POST /api/assets or POST /api/assets/uploads | Public or private |
| Your app's users | The SDK's useUpload - a workspace image or a profile picture | Public, the user as creator |
| Your forms' visitors | A form's file field | Private |
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}}'| Field | Contents | Default |
|---|---|---|
enabled | Whether your app's users may upload at all | true |
maxFileBytes | The largest one file may be, from 102,400 (100 KB) to 5,242,880 (5 MB) | 5 MB |
perUserBytes | The most one user may hold in all, at least 100 KB, or null for no per-user limit | null |
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.
| Method | Path | Body or answer |
|---|---|---|
POST | /api/v1/public/uploads | { purpose, name, size, contentType }, answered like an asset intent: uploadId, url, method, headers, expiresAt, maxBytes |
PUT | the signed url | The 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.
| Status | Code | When |
|---|---|---|
400 | INVALID_UPLOAD | A 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 |
403 | UPLOADS_DISABLED | You turned end-user uploads off |
403 | USER_UPLOAD_LIMIT | The file would take the user past perUserBytes; the body carries heldBytes and limitBytes |
413 | UPLOAD_TOO_LARGE | The file is over maxFileBytes; the body carries maxBytes |
429 | Too many uploads from one address, or for your organization today | |
503 | UPLOADS_UNAVAILABLE | Your organization cannot take the file: storage full, or a plan limit |
501 | UPLOADS_UNAVAILABLE | The server has no direct uploads configured |
409 | UPLOAD_NOT_RECEIVED, UPLOAD_FINALIZING | On 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
imagewhen creating or updating the workspace (PUT /api/v1/public/workspaces/:idwithnameandimage). - A profile picture:
PATCH /api/v1/public/profilewith{ "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
- Content - where assets get embedded.
- Admin API - tokens and list-query parameters.
- Error codes - every upload refusal in one table.