POST /v1/uploads endpoint streams file bytes through our API gateway, which caps inbound HTTP/1 request bodies at 32 MiB (33,554,432 bytes). Requests above that size are rejected with a bare 413 Payload Too Large before they reach the upload handler — the response carries no error envelope, no limit, and no remedy, because it never touches our application. GET /v1/uploads/limits reports this ceiling machine-readably as max_direct_upload_bytes so clients can route before sending bytes.
For larger files (up to max_file_bytes, the same ceiling as in-app uploads), use the two-step signed-URL flow below. max_file_bytes is per-workspace, not a fixed platform number — 250 MB is the deployment maximum and a free workspace is capped lower — so read it from GET /v1/uploads/limits and cache it per API key rather than hard-coding a number. That response also carries max_file_bytes_is_plan_limit: when it is true the ceiling is the workspace’s plan rather than the platform, and upgrading raises it. The bytes go straight from your client to our object storage, bypassing the gateway entirely. The moda CLI does this automatically: moda file upload switches to the signed-URL flow for files above ~30 MiB, so large uploads need no special handling there (CLI versions ≤ 0.17.29 predate the auto-routing and surface the gateway’s opaque 413 instead).
If you’re uploading small files (under ~30 MiB) the multipart
POST /v1/uploads endpoint is simpler and a single round trip. Reach for the two-step flow only when you actually need it.When to use this flow
- Any file larger than ~32 MiB (PPTX decks with embedded images, multi-page PDFs, high-resolution source assets).
- Files where you’d rather not have the bytes traverse our gateway (e.g. you’re streaming directly from another cloud bucket).
End-to-end recipe
1. Request a signed PUT URL
upload_url is a GCS V4 signed URL. It is valid for expires_in_seconds (default 600, max 3600) and is bound to the mime_type you passed — the PUT must send a matching Content-Type header or storage rejects it.
Keep the storage_key around — you’ll pass it back at step 3.
2. PUT the file bytes directly to storage
Authorization header on this request — the signed URL itself is the capability. The PUT goes directly to object storage, so the response status comes from GCS (a successful PUT returns 200 OK).
3. Register the upload to receive a stable file id
POST /v1/uploads):
register hashes the staged blob and deduplicates against existing team files by content hash, so re-uploading the same bytes returns the existing file_id with was_duplicate: true. Because hashing requires reading the full blob, register for a 100+ MB file can take several seconds — set your client timeout accordingly. The pending blob at storage_key is deleted once the canonical file row is created (or, on a dedup hit, immediately).
4. Attach the file to a design task
Use the returnedid exactly as you would from POST /v1/uploads:
Errors and edge cases
Retries and idempotency
Neither endpoint takes anidempotency_key:
/uploads/urlis safe to retry — each call just mints a fresh, independent signed URL. A URL you minted and didn’t use is harmless (a PUT never happened, so nothing is stored)./uploads/registeris naturally idempotent on content hash. Callingregistertwice with the samestorage_keyafter a successful first call will fail the second time with422 "No blob found"(the first call deleted the staged blob). Callingregisterfor the same file content via a different signed URL returns the existing file withwas_duplicate: true.
Required scope
Both endpoints require theuploads:write scope on your API key — the same scope as POST /v1/uploads.
Cleanup
You don’t need to do anything in the happy path:register deletes the pending blob after the canonical file row is created (or immediately, on a dedup hit). If you mint a signed URL and never PUT to it, nothing is stored. If you PUT but never call register, the staged blob is left in the pending prefix — there’s no scheduled sweep today, so prefer re-using the same storage_key on retry rather than minting fresh URLs you abandon.