Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.moda.app/llms.txt

Use this file to discover all available pages before exploring further.

The multipart POST /v1/uploads endpoint streams file bytes through our API gateway, which caps inbound HTTP/1 request bodies at roughly 32 MiB. Requests above that size are rejected with 413 Payload Too Large before they reach the upload handler. For larger files (up to MAX_FILE_SIZE_BYTES, currently 250 MB), use the two-step signed-URL flow below. The bytes go straight from your client to our object storage, bypassing the gateway entirely.
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

curl -X POST https://api.moda.app/v1/uploads/url \
  -H "Authorization: Bearer $MODA_API_KEY" \
  -H "Moda-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "q4_pitch_deck.pptx",
    "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
    "expires_in_seconds": 900
  }'
Response:
{
  "upload_url": "https://storage.googleapis.com/...signed...",
  "storage_key": "mcp-pending-uploads/<team>/<token>/q4_pitch_deck.pptx",
  "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  "expires_in_seconds": 900,
  "instructions": "PUT the raw file bytes to `upload_url` ..."
}
The 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

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/vnd.openxmlformats-officedocument.presentationml.presentation" \
  --data-binary @q4_pitch_deck.pptx
No 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

curl -X POST https://api.moda.app/v1/uploads/register \
  -H "Authorization: Bearer $MODA_API_KEY" \
  -H "Moda-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{
    "storage_key": "mcp-pending-uploads/<team>/<token>/q4_pitch_deck.pptx",
    "filename": "q4_pitch_deck.pptx",
    "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
  }'
Response (the same shape as POST /v1/uploads):
{
  "id": "file_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
  "url": "https://api.moda.app/api/v2/images/ref/file_01HT9WK8N3M2J4A5Z6P7Q8R9TV?v=a1b2c3d4",
  "filename": "q4_pitch_deck.pptx",
  "mime_type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  "size_bytes": 78643200,
  "was_duplicate": false
}
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 returned id exactly as you would from POST /v1/uploads:
{
  "prompt": "Rebrand this deck for our enterprise tier",
  "attachments": [
    { "file_id": "file_01HT9WK8N3M2J4A5Z6P7Q8R9TV", "role": "source" }
  ]
}
See Start a design task for the full request shape.

Errors and edge cases

StatusReturned byMeaning
415 on /uploads/urlModaThe mime_type isn’t on the allow-list (no executables, scripts, etc.).
422 on /uploads/registerModaThe storage_key is missing, malformed, or isn’t prefixed for your team — i.e. it wasn’t issued by a prior /uploads/url call on the same API key.
422 "No blob found at storage_key"ModaThe PUT never completed, the signed URL expired before the bytes arrived, or you registered the wrong key. Re-mint a fresh URL and re-PUT.
403 on /uploads/registerModaThe API key’s user is no longer a member of the team the key is bound to. Distinct from the prefix-mismatch 422 above.
413 on /uploads/registerModaThe file is above MAX_FILE_SIZE_BYTES (250 MB). The oversized blob is deleted automatically.
403 on the PUTGCSSigned URL expired, content type doesn’t match the one you minted, or the URL was tampered with. Mint a new one.

Retries and idempotency

Neither endpoint takes an idempotency_key:
  • /uploads/url is 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/register is naturally idempotent on content hash. Calling register twice with the same storage_key after a successful first call will fail the second time with 422 "No blob found" (the first call deleted the staged blob). Calling register for the same file content via a different signed URL returns the existing file with was_duplicate: true.

Required scope

Both endpoints require the uploads: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.