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

> One operation, three calls — get a URL, send the bytes, confirm they landed.


# Upload files

Uploading is one operation and three calls. The file goes from the client
straight to storage; only the decision passes through us, which is why there is
a step before and a step after.

```
1. POST /v1/files/upload-intent   → a presigned URL      your server, sk_…
2. PUT  {uploadUrl}               → the bytes            anywhere, no key
3. POST /v1/files/confirm         → we check they landed your server, sk_…
```

Steps 1 and 3 are [server-side only](/docs/files-api). Step 2 is not: the
presigned URL is safe to hand to a browser, which is the entire reason the flow
has three steps rather than one.

## POST /v1/files/upload-intent

Creates a pending record and returns a URL valid for fifteen minutes. The
signature covers content type, content length, visibility, project and file id —
so nothing declared here can be changed by whoever holds the URL. That is the
reason this is not a single call.

```json Request body
{
  "filename": "photo.jpg",              // Required — the original name
  "contentType": "image/jpeg",          // Required — locked into the signature
  "size": 204800,                       // Required — locked into the signature
  "visibility": "public",               // Optional — default "private"
  "ownerUserId": "user_123",            // Optional — who uploaded it
  "entity": {                           // Optional — attach to your own object
    "type": "product",                  //   Required with the entity
    "id": "prod_abc123",                //   Required with the entity
    "role": "gallery",                  //   Optional — what it is for
    "position": 0                       //   Optional — order, 0 first
  },
  "transformations": {                  // Optional — image sizes
    "image": {
      "enabled": true,                  //   Default false
      "visibility": "public"            //   Default "private"
    }
  }
}
```

```json Response
{
  "fileId": "abc123def456",
  "uploadUrl": "https://…?X-Amz-Signature=…",
  "key": "files/originals/public/proj/abc123def456/photo-k9x2m7.jpg",
  "expiresIn": 900
}
```

## Large files come back split

Above 100 MB there is no `uploadUrl`. You get a `multipart` block instead — one
signed URL per part, all of them at once, valid for six hours:

```json Response for a large file
{
  "fileId": "abc123def456",
  "key": "files/originals/private/proj/abc123def456/big-k9x2m7.mp4",
  "expiresIn": 21600,
  "multipart": {
    "uploadId": "2~xY9…",
    "partSize": 10485760,
    "parts": [
      { "partNumber": 1, "url": "https://…" },
      { "partNumber": 2, "url": "https://…" }
    ]
  }
}
```

**Check which one you got rather than checking the size yourself.** The
threshold is ours to move, and a client that hardcoded it breaks the day we do.

Upload each part with a `PUT` — in parallel if you like — and keep the `ETag`
header each one returns. A part that fails is one part to retry, not the whole
file, which is the entire reason this exists.

Then assemble them:

```json POST /v1/files/complete
{
  "fileId": "abc123def456",
  "uploadId": "2~xY9…",
  "parts": [
    { "partNumber": 1, "etag": "\"a1b2…\"" },
    { "partNumber": 2, "etag": "\"c3d4…\"" }
  ]
}
```

Part numbers may arrive in any order — you uploaded them concurrently, so
demanding an order would be demanding you undo that. Completing replaces the
`confirm` step; the response is the same confirmed file.

Only the server can assemble a multipart upload, and that is what keeps it safe:
holding part URLs lets a client write parts and nothing else. The object does
not exist until you call complete, and complete checks it — **an assembled size
that does not match what was declared is discarded**, because a part URL cannot
pin a length the way a single PUT's signature does:

```json 400
{ "error": "That upload declared 125829120 bytes and assembled to 20971520. It has been discarded." }
```

Abandoned parts are billed like any other storage, so they are aborted after
seven days. Finishing or failing promptly costs nothing; walking away costs a
week of whatever you uploaded.

## PUT {uploadUrl}

Straight to storage. Content type and length must match what was declared —
that is what the signature covers.

```bash Sending the bytes
curl -X PUT "$UPLOAD_URL" \
  -H "content-type: image/jpeg" \
  --data-binary @photo.jpg
```

In a browser, use `XMLHttpRequest` if you want a progress bar. `fetch` cannot
report upload progress, so a large file shows nothing until it finishes — which
is indistinguishable from a hang.

```ts With progress
const xhr = new XMLHttpRequest();
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("content-type", file.type);
xhr.upload.onprogress = (event) => {
  if (event.lengthComputable) setSent(event.loaded);
};
xhr.send(file);
```

## POST /v1/files/confirm

Marks the record confirmed, creates its entity attachment, and makes the file
visible to every other call.

This asks storage directly whether the object exists. A client reporting success
is not evidence of success, and a record claiming a file exists when it does not
is worse than no record at all.

```json Request body
{ "fileId": "abc123def456" }
```

```json Response
{
  "file": {
    "id": "abc123def456",
    "originalFilename": "photo.jpg",
    "uploadStatus": "confirmed",
    "url": "https://cdn…/files/originals/public/…",
    "variants": [{ "width": 100, "url": "…" }],
    "srcset": "…100w, …300w, …",
    "entity": { "type": "product", "id": "prod_abc123", "role": "gallery", "position": 0 }
  }
}
```

Confirming twice is harmless — the second call returns the same file.

## If you never confirm

The file appears in no listing, and an unconfirmed record is swept up an hour
later along with anything that did reach storage. An abandoned upload costs
nothing and leaves nothing behind.

## Errors

```json 400 — type not accepted
{ "error": "Content type not allowed" }
```

```json 400 — too large
{ "error": "That file is 95.4 MB. The limit is 50 MB for images, so every image can have its smaller versions built." }
```

```json 400 — missing fields
{ "error": "filename, contentType, and size are required" }
```

```json 400 — confirmed too early
{ "error": "The file is not in storage yet. Upload it to the presigned URL, then confirm." }
```

Also `400` for a `visibility` that isn't `public` or `private`, and for an
`entity` missing its `type` or `id`. See [limits](/docs/files-api/limits).
