Files API

building

Every endpoint, one page each, with the request and response it actually returns.

Files API

Base URL: /v1/files. One page per operation — the request, the response, and the errors that operation can actually produce.

Every example here uses two shell variables. Set them once and the rest of these pages paste straight into a terminal:

bash
export BASE="https://<your-deployment>"
export KEY="sk_live_…"

Both are on your project page in the console. New here? Start with the quickstart.

There is no SDK yet. This HTTP API is the whole interface, so everything here is a request you can make with curl, fetch, or whatever your language uses. Nothing below assumes a client library, and no example calls one.

Authentication
1curl "$BASE/v1/files" \2  -H "authorization: Bearer $KEY"

The key identifies the project. There is no project id to send and no default project: an unresolvable key is refused before anything else happens, with the same message whether it is unknown, revoked or malformed.

Rejected
1{ "error": "Invalid API key" }

Revoking a key or suspending a project takes effect within a minute.

Concepts

Five words appear throughout, and everything else follows from them.

  • File — an object in storage, served through the CDN.
  • Visibilitypublic gives a permanent URL; private gives one signed for five minutes. It is a path prefix rather than a flag, so changing it moves the object.
  • Entity — an optional attachment (type, id, role, position) linking a file to one of your own objects, so you can ask for "this product's gallery" instead of keeping file ids yourself.
  • Image sizes — resized WebP copies at fixed widths, built the first time each is requested and then kept. Each is built at most once, ever.
  • Variant visibility — independent of the original. A private document can have public thumbnails.

The widths are fixed:

100  300  400  600  800  1000  1200

Every resizable image comes back with all seven as a srcset. Hand it to an and let the browser choose — it knows the viewport and the pixel ratio, and its choice is what decides which sizes ever get built.

Operations

Grouped by what you are doing, not by how many calls it takes. Uploading is one operation and three calls; deleting is one operation whether you name a file or an entity.

OperationCalls
Upload filesPOST /upload-intentPUTPOST /confirm
Get filesGET /v1/files, GET /v1/files/{id}
Update filesPATCH /v1/files/{id}
Delete filesDELETE /v1/files/{id}, DELETE /v1/files
RestoringGET /v1/files/deleted, POST /v1/files/{id}/restore
Signed URLsPOST /v1/files/{id}/url

Create, read, update, delete — named for what you do to a file rather than what the acronym calls it — plus the undo for delete, and one supporting operation for the URLs a private file needs re-signing on.

This is a server-side API

It needs a secret key — sk_… — and secret keys belong on a server.

That is not a recommendation you could ignore carefully. A publishable key (pk_…) is refused outright:

403
1{ "error": "This is a publishable key. Files is a server-side API — use a secret key (sk_…)." }

The reason is that there is nothing here a browser needs. Serving files takes no key at all — a public URL is permanent and a private one is signed, both answered by the CDN. So the only thing a browser would use a key for is this management API, which can delete a project's files. A credential that ships inside an app is public the moment it deploys, and those two facts do not belong in the same key.

There is also no CORS on these routes, which means a browser cannot call them even if a secret key ends up in a bundle by mistake. That is deliberate: a protection that works without anyone reading a warning is worth more than one that does not.

If your frontend needs to upload, have it ask your own backend, and let that call upload-intent with the secret key. The presigned URL that comes back is safe to hand to the browser — it is scoped to one object, one content type, one length, and fifteen minutes.

browser  →  your server  →  POST /v1/files/upload-intent   (sk_…)
browser  ←  your server  ←  { uploadUrl }
browser  →  storage          PUT the bytes                 (no key)
browser  →  your server  →  POST /v1/files/confirm         (sk_…)

Reading is simpler still: your server fetches the file's url and hands it over. The CDN serves it with no key involved at all — permanent if the file is public, signed for five minutes if it is private.

Scopes

A key carries what it may do, as service:action. Files understands two:

ScopeCovers
files:readList, fetch, re-sign a private URL
files:writeUpload, confirm, update, delete

A new key is unrestricted by default — *, meaning everything the project can do including services that ship later. That is what almost every integration wants, and a key listing today's services would quietly fail against tomorrow's.

Narrow one when it is going somewhere that only needs to look: a static site build, an analytics job, a contractor's machine. Write implies read, because a key that can replace a file and not read it back is a shape nobody wants.

403 — scoped too narrowly
1{ "error": "This key is not scoped for files:write." }

Scopes narrow; they never widen. A publishable key carrying files:write is still refused, because the kind is checked first and files is server-side only.

Rate limits

600 requests a minute per key. Every response says where you stand, so you can slow down before being refused rather than after:

Any response
1x-ratelimit-limit: 6002x-ratelimit-remaining: 4123x-ratelimit-reset: 1786952640

Past the limit the answer is 429, with Retry-After in seconds:

429
1{ "error": "Too many requests. Slow down and try again shortly." }

The window is fixed and one minute long, and x-ratelimit-reset is the unix second it rolls over. A caller that waits for Retry-After is never refused twice for the same burst.

Two things worth knowing:

Serving files is not counted. The limit is on this API — listing, uploading, changing, deleting. The bytes themselves come from the CDN, which needs no key and has no per-key limit, so a page rendering a thousand images makes zero requests against it.

It is per key, not per project. A key doing bulk work cannot starve the one your application is using — which is a reason to give a migration script or a nightly job its own key rather than sharing.

Errors

Every failure is { "error": "…" } with a meaningful status.

StatusMeans
400The request is wrong; the message says how
401Missing, malformed, unknown or revoked key
403A publishable key, or one not scoped for what you asked
429Over the rate limit; Retry-After says how long to wait
404No such file — or it belongs to another project
500Ours. The message is deliberately generic

404 covering both "gone" and "not yours" is deliberate: telling one project that another's file id exists is a leak, so the two are indistinguishable.

A 500 never carries the underlying message. Internal errors can contain a connection string or a signed URL, so the detail stays in our logs and you get a flat That request couldn't be completed.

See also