Files API

building

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. 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.

Request body
1{2  "filename": "photo.jpg",              // Required — the original name3  "contentType": "image/jpeg",          // Required — locked into the signature4  "size": 204800,                       // Required — locked into the signature5  "visibility": "public",               // Optional — default "private"6  "ownerUserId": "user_123",            // Optional — who uploaded it7  "entity": {                           // Optional — attach to your own object8    "type": "product",                  //   Required with the entity9    "id": "prod_abc123",                //   Required with the entity10    "role": "gallery",                  //   Optional — what it is for11    "position": 0                       //   Optional — order, 0 first12  },13  "transformations": {                  // Optional — image sizes14    "image": {15      "enabled": true,                  //   Default false16      "visibility": "public"            //   Default "private"17    }18  }19}
Response
1{2  "fileId": "abc123def456",3  "uploadUrl": "https://…?X-Amz-Signature=…",4  "key": "files/originals/public/proj/abc123def456/photo-k9x2m7.jpg",5  "expiresIn": 9006}

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:

Response for a large file
1{2  "fileId": "abc123def456",3  "key": "files/originals/private/proj/abc123def456/big-k9x2m7.mp4",4  "expiresIn": 21600,5  "multipart": {6    "uploadId": "2~xY9…",7    "partSize": 10485760,8    "parts": [9      { "partNumber": 1, "url": "https://…" },10      { "partNumber": 2, "url": "https://…" }11    ]12  }13}

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:

POST /v1/files/complete
1{2  "fileId": "abc123def456",3  "uploadId": "2~xY9…",4  "parts": [5    { "partNumber": 1, "etag": "\"a1b2…\"" },6    { "partNumber": 2, "etag": "\"c3d4…\"" }7  ]8}

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:

400
1{ "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.

Sending the bytes
1curl -X PUT "$UPLOAD_URL" \2  -H "content-type: image/jpeg" \3  --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.

With progress
1const xhr = new XMLHttpRequest();2xhr.open("PUT", uploadUrl);3xhr.setRequestHeader("content-type", file.type);4xhr.upload.onprogress = (event) => {5  if (event.lengthComputable) setSent(event.loaded);6};7xhr.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.

Request body
1{ "fileId": "abc123def456" }
Response
1{2  "file": {3    "id": "abc123def456",4    "originalFilename": "photo.jpg",5    "uploadStatus": "confirmed",6    "url": "https://cdn…/files/originals/public/…",7    "variants": [{ "width": 100, "url": "…" }],8    "srcset": "…100w, …300w, …",9    "entity": { "type": "product", "id": "prod_abc123", "role": "gallery", "position": 0 }10  }11}

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

400 — type not accepted
1{ "error": "Content type not allowed" }
400 — too large
1{ "error": "That file is 95.4 MB. The limit is 50 MB for images, so every image can have its smaller versions built." }
400 — missing fields
1{ "error": "filename, contentType, and size are required" }
400 — confirmed too early
1{ "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.