Files API
buildingEvery 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:
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.
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.
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.
- Visibility —
publicgives a permanent URL;privategives 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 1200Every 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.
| Operation | Calls |
|---|---|
| Upload files | POST /upload-intent → PUT → POST /confirm |
| Get files | GET /v1/files, GET /v1/files/{id} |
| Update files | PATCH /v1/files/{id} |
| Delete files | DELETE /v1/files/{id}, DELETE /v1/files |
| Restoring | GET /v1/files/deleted, POST /v1/files/{id}/restore |
| Signed URLs | POST /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:
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:
| Scope | Covers |
|---|---|
files:read | List, fetch, re-sign a private URL |
files:write | Upload, 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.
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:
1x-ratelimit-limit: 6002x-ratelimit-remaining: 4123x-ratelimit-reset: 1786952640Past the limit the answer is 429, with Retry-After in seconds:
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.
| Status | Means |
|---|---|
400 | The request is wrong; the message says how |
401 | Missing, malformed, unknown or revoked key |
403 | A publishable key, or one not scoped for what you asked |
429 | Over the rate limit; Retry-After says how long to wait |
404 | No such file — or it belongs to another project |
500 | Ours. 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
- The file object — every field, once
- Limits and formats — sizes, accepted types, widths