Uploads
Four endpoints. All requireuploads:write.
Entrypoints
POST /v1/uploads — multipart (files up to ~32 MiB)
POST /v1/uploads/url + POST /v1/uploads/register — signed URL (large files)
The bytes go straight from your client to object storage, bypassing our gateway entirely.
register returns the same FileUploadResponse shape as multipart, including content-hash dedupe.
Pass mime_type on step 3. It’s optional, but when omitted register re-derives the type from the basename embedded in storage_key — so a filename with no recognizable extension (perfectly legal at step 1, which takes an explicit mime_type) fails with 415 "Missing or unrecognizable content type" after you’ve already paid for the whole PUT. Send the same mime_type you declared at step 1 and that can’t happen. filename is optional too and defaults to the same basename.
Signed URLs expire after expires_in_seconds (60–3600, default 600). The TTL bounds the PUT only — register doesn’t check it. Because register hashes the whole blob, a 100+ MB file can take several seconds; set your client timeout accordingly.
POST /v1/uploads/from-url
FileUploadResponse shape. Use when the file is already hosted publicly and you don’t want to proxy it through your own machine. The fetch has a 30s budget; a slow source returns 400.
SSRF-validated server-side — internal / localhost / metadata URLs are rejected with 422.
Supported types
- Images: any
image/*(PNG, JPEG, WebP, GIF, SVG, …) - Documents: PDF, PPTX / PPT, DOCX / DOC, XLSX / XLS
- Text: CSV, plain text, Markdown, JSON
- Video (upload + in-app viewing, no transcoding): MP4, WebM, MOV
- Audio (upload + in-app playback, no transcoding): MP3, M4A, AAC, WAV, FLAC, OGG / OGA
- Figma local-copy exports:
.fig,.deck(brand-kit import)
415 unsupported media type.
Size limits
The gateway cap is fixed; the application cap varies by workspace plan:max_file_bytes is the application cap and it applies to every path. It is per-workspace, not per-deployment — 250 MB is the 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. The same response carries max_file_bytes_is_plan_limit: when it is true, the ceiling is the workspace’s plan rather than the platform, so the remedy to report is an upgrade and not only a smaller file. Oversize uploads return 413 payload too large — on POST /v1/uploads/register, the staged blob is deleted automatically.
Deduplication
Content-hash dedupe. Uploading the same bytes twice returns:id / url / filename / mime_type / size_bytes point at the existing record. Safe to call repeatedly — no duplicate storage, no duplicate billing.
Using in a task
Roles
Pick the right role — it determines what the agent does with the file. See the Attachments section of
moda-mcp/references/gotchas.md for details; the attachment shape is identical between MCP and REST.
URL-form attachment (legacy, less preferred)
Worked example
Common wrong guesses
- Posting files as JSON-encoded base64 into
POST /v1/uploads. Use multipart (multipart/form-data). - Pushing a >32 MiB file at
POST /v1/uploadsand reading the413as “the file is over Moda’s limit”. The app cap ismax_file_bytesfromGET /v1/uploads/limits, at most 250 MB; that413is the gateway. Switch to the signed-URL flow. - PUT-ing to
upload_urlwith a differentContent-Typethan you declared on/uploads/url. The signature is bound to the content type — a mismatch fails the PUT at storage. - Calling
POST /v1/uploads/registerbefore the PUT finished, or after a signed URL expired without the bytes ever landing. Both return422with “No blob found at storage_key”. Note the TTL bounds the PUT only —POST /v1/uploads/registerdoes not check it, so once the blob is staged you can register whenever. A late register is never a reason to re-upload. - Using the proxy URL (
/api/v2/images/ref/…) as if it were stable public content. It includes an auth hash; it’s a stable reference for use in Moda-side operations, not a CDN URL. - Re-uploading on every run when content hasn’t changed. Dedupe handles it server-side, but you still pay a roundtrip. Cache
file_idin your app. - Skipping
rolein the attachment. It drops back to a generic “reference” semantic, which is usually not what you want. Always setrole. - Mixing
file_idandurlin one attachment item. Pick one per item. Mix items within the array is fine.
Upstream
docs.moda.app/api/uploads/uploadFiledocs.moda.app/api/uploads/uploadFromUrldocs.moda.app/api-reference/large-file-uploads— the signed-URL flow in full