> ## 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.

# Large file uploads

> Upload files above the ~32 MiB gateway limit by PUT-ing directly to storage via a short-lived signed URL.

The multipart [`POST /v1/uploads`](/api-reference/uploads/upload-file) 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.

<Note>
  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.
</Note>

## 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

```bash theme={null}
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:

```json theme={null}
{
  "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](https://cloud.google.com/storage/docs/access-control/signed-urls). 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

```bash theme={null}
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

```bash theme={null}
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`):

```json theme={null}
{
  "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`:

```json theme={null}
{
  "prompt": "Rebrand this deck for our enterprise tier",
  "attachments": [
    { "file_id": "file_01HT9WK8N3M2J4A5Z6P7Q8R9TV", "role": "source" }
  ]
}
```

See [Start a design task](/api-reference/tasks/start-design-task) for the full request shape.

## Errors and edge cases

| Status                               | Returned by | Meaning                                                                                                                                              |
| ------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `415` on `/uploads/url`              | Moda        | The `mime_type` isn't on the allow-list (no executables, scripts, etc.).                                                                             |
| `422` on `/uploads/register`         | Moda        | The `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"` | Moda        | The 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/register`         | Moda        | The 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/register`         | Moda        | The file is above `MAX_FILE_SIZE_BYTES` (250 MB). The oversized blob is deleted automatically.                                                       |
| `403` on the PUT                     | GCS         | Signed 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.
