Files API
buildingOne 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.
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.
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}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:
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:
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:
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.
Straight to storage. Content type and length must match what was declared — that is what the signature covers.
1curl -X PUT "$UPLOAD_URL" \2 -H "content-type: image/jpeg" \3 --data-binary @photo.jpgIn 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.
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);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.
1{ "fileId": "abc123def456" }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
1{ "error": "Content type not allowed" }1{ "error": "That file is 95.4 MB. The limit is 50 MB for images, so every image can have its smaller versions built." }1{ "error": "filename, contentType, and size are required" }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.