<!-- section: Files API · status: building · source: docs/files-api/01-index.md -->

> 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](/docs/start/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.

```bash Authentication
curl "$BASE/v1/files" \
  -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.

```json Rejected
{ "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** — `public` 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
`<img>` 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](/docs/files-api/upload-files) | `POST /upload-intent` → `PUT` → `POST /confirm` |
| [Get files](/docs/files-api/get-files) | `GET /v1/files`, `GET /v1/files/{id}` |
| [Update files](/docs/files-api/update-files) | `PATCH /v1/files/{id}` |
| [Delete files](/docs/files-api/delete-files) | `DELETE /v1/files/{id}`, `DELETE /v1/files` |
| [Restoring](/docs/files-api/restoring) | `GET /v1/files/deleted`, `POST /v1/files/{id}/restore` |
| [Signed URLs](/docs/files-api/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:

```json 403
{ "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](/docs/files-api/upload-files) 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.

```json 403 — scoped too narrowly
{ "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:

```bash Any response
x-ratelimit-limit: 600
x-ratelimit-remaining: 412
x-ratelimit-reset: 1786952640
```

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

```json 429
{ "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](/docs/files-api/file-object) — every field, once
- [Limits and formats](/docs/files-api/limits) — sizes, accepted types, widths
