Skip to main content

Uploads

Four endpoints. All require uploads:write.

Entrypoints

POST /v1/uploads — multipart (files up to ~32 MiB)

Response:
Use when you have the bytes locally and the file is under ~32 MiB. Above that, use the signed-URL flow below — the gateway rejects the request before it reaches this handler.

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

Server fetches the URL, validates MIME, stores. Returns the same 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)
Anything else is rejected with 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)

No role metadata. Still works for hosted public URLs when you want to avoid the upload roundtrip. Mix with file-id-form in the same list freely.

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/uploads and reading the 413 as “the file is over Moda’s limit”. The app cap is max_file_bytes from GET /v1/uploads/limits, at most 250 MB; that 413 is the gateway. Switch to the signed-URL flow.
  • PUT-ing to upload_url with a different Content-Type than you declared on /uploads/url. The signature is bound to the content type — a mismatch fails the PUT at storage.
  • Calling POST /v1/uploads/register before the PUT finished, or after a signed URL expired without the bytes ever landing. Both return 422 with “No blob found at storage_key”. Note the TTL bounds the PUT only — POST /v1/uploads/register does 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_id in your app.
  • Skipping role in the attachment. It drops back to a generic “reference” semantic, which is usually not what you want. Always set role.
  • Mixing file_id and url in one attachment item. Pick one per item. Mix items within the array is fine.

Upstream