# Uploads (/api/uploads)



Send your own file (a product shot, a face, a clip, a recording) and get an `ast_` id to put in a config. Three steps. Two are RiffAds calls.

<Callout type="warn" title="The middle step is yours">
  `POST /uploads` moves no bytes. It returns a signed `upload_url`. **You** PUT the file there. Then `POST /uploads/{assetId}/finalize` checks it. Skipping the PUT is the first mistake everyone makes.
</Callout>

## The three steps [#the-three-steps]

<Steps>
  <Step>
    ### Reserve an id and a URL [#reserve-an-id-and-a-url]

    Send filename, content type and exact byte count. Measure the file first. Storage refuses any other size.

    ```bash title="Terminal"
    curl -s -X POST https://app.riffads.com/api/v1/uploads \
      -H "Authorization: Bearer $RIFFADS_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "filename": "product-hero.png",
        "content_type": "image/png",
        "size_bytes": 318244
      }'
    ```

    ```json title="Response: 201"
    {
      "ok": true,
      "asset_id": "ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
      "upload_url": "https://<storage-host>/riffads/orgs/<org-id>/assets/ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011.png?X-Amz-Signature=...",
      "upload_expires_at": "2026-09-17T10:09:11.000Z",
      "upload_expires_in_seconds": 300,
      "kind": "image",
      "content_type": "image/png",
      "size_bytes": 318244,
      "max_bytes_for_kind": 20971520,
      "content_checked": true,
      "next_action": "Send the file with a single HTTP PUT request at upload_url, with the same content type and exactly 318244 bytes, within 5 minutes. Then call finalize_upload with this asset_id. The file cannot be used in a generation until that second call comes back usable. Images are checked against our content policy at that point."
    }
    ```
  </Step>

  <Step>
    ### PUT the bytes [#put-the-bytes]

    Straight to storage. No API key. No RiffAds host.

    ```bash title="Terminal"
    curl -s -X PUT "https://<storage-host>/riffads/orgs/<org-id>/assets/ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011.png?X-Amz-Signature=..." \
      -H "Content-Type: image/png" \
      --data-binary @product-hero.png
    ```
  </Step>

  <Step>
    ### Finalize and read `usable` [#finalize-and-read-usable]

    No body. Id in the path.

    ```bash title="Terminal"
    curl -s -X POST \
      https://app.riffads.com/api/v1/uploads/ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011/finalize \
      -H "Authorization: Bearer $RIFFADS_API_KEY"
    ```

    ```json title="Response: 200"
    {
      "ok": true,
      "asset_id": "ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
      "scan_status": "clean",
      "usable": true,
      "kind": "image",
      "duration_ms": null,
      "next_action": "This file is ready. Pass this asset_id into a capability config wherever it asks for a file, then call estimate_generation before you spend anything."
    }
    ```
  </Step>
</Steps>

* CLI: `riffads upload <file>` does all three and prints the asset id. [Commands](/cli/commands).
* MCP: `create_upload` and `finalize_upload`, both in the `spend` set. [Tools](/mcp/tools).

## Reserve [#reserve]

<Endpoint method="POST" path="/uploads" />

* Scope **Generate**.
* Answers `201`, no `Location` header.
* Strict body: an unknown key is `400 invalid_config`.

<TypeTable
  type="{
  filename: {
    type: 'string',
    required: true,
    description: '1 to 255 characters. Used for the stored object name only.',
  },
  content_type: {
    type: 'string',
    required: true,
    description: 'One of the accepted types below. Parameters are stripped and it is lowercased before matching.',
  },
  size_bytes: {
    type: 'integer',
    required: true,
    description: 'Real file size. Above zero, at or under the ceiling for its kind.',
  },
  checksum_sha256: {
    type: 'string',
    required: false,
    description: 'Base64 SHA-256 of the bytes. Storage enforces it where supported.',
  },
}"
/>

### Accepted types and sizes [#accepted-types-and-sizes]

| Kind    | Content types                                                                    | Maximum bytes        |
| ------- | -------------------------------------------------------------------------------- | -------------------- |
| `image` | `image/jpeg`, `image/png`, `image/webp`, `image/gif`                             | 20 MB (`20971520`)   |
| `video` | `video/mp4`, `video/webm`, `video/quicktime`                                     | 100 MB (`104857600`) |
| `audio` | `audio/mpeg`, `audio/mp3`, `audio/wav`, `audio/x-wav`, `audio/webm`, `audio/ogg` | 25 MB (`26214400`)   |

Kind comes from `content_type`. Any other type is refused, and the message lists every accepted type.

### Response fields [#response-fields]

| Field                       | Type                          | Notes                                                     |
| --------------------------- | ----------------------------- | --------------------------------------------------------- |
| `asset_id`                  | string                        | `ast_` plus 32 lowercase hex characters                   |
| `upload_url`                | string                        | Signed PUT. One use, one file, one type, one exact length |
| `upload_expires_at`         | string                        | ISO 8601                                                  |
| `upload_expires_in_seconds` | integer                       | `300`                                                     |
| `kind`                      | `image` \| `video` \| `audio` | From `content_type`                                       |
| `content_type`              | string                        | Echoed                                                    |
| `size_bytes`                | integer                       | Echoed                                                    |
| `max_bytes_for_kind`        | integer                       | Ceiling for this kind                                     |
| `content_checked`           | boolean                       | `true` for images only                                    |
| `next_action`               | string                        | The two remaining steps, with your numbers                |

<Callout type="info" title="Only images are content-checked">
  Video and audio contents are never read. Don't tell a user those uploads were reviewed.
</Callout>

## The PUT [#the-put]

Break a rule here and it fails at finalize, not at the PUT.

* **One request.** Single PUT. No multipart, no chunks.
* **Same content type** you declared.
* **Exactly `size_bytes`** bytes.
* **Within 5 minutes**, once. A used or expired link is gone.

A reservation counts toward the pending cap until you finalize it or it ages out.

## Finalize [#finalize]

<Endpoint method="POST" path="/uploads/{assetId}/finalize" />

* Scope **Generate**.
* Answers `200`. &#x2A;*No body.**
* **Safe to call twice.** A scanned file answers from its stored status. No second check.

| Field         | Type                              | Notes                                               |
| ------------- | --------------------------------- | --------------------------------------------------- |
| `asset_id`    | string                            | Echoed                                              |
| `scan_status` | `clean` \| `flagged` \| `pending` | `pending` is not reachable today                    |
| `usable`      | boolean                           | `true` only when `scan_status` is `clean`           |
| `kind`        | `image` \| `video` \| `audio`     | Measured from the bytes, not your declaration       |
| `duration_ms` | integer \| null                   | `null` for images, or when the file can't be probed |
| `next_action` | string                            | One sentence per scan status                        |

### A refused file is a 200 [#a-refused-file-is-a-200]

```json title="Response: 200"
{
  "ok": true,
  "asset_id": "ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
  "scan_status": "flagged",
  "usable": false,
  "kind": "image",
  "duration_ms": null,
  "next_action": "This file was refused by our content policy and can never be used in a generation. Do not call finalize_upload again and do not send the same file again: the answer will not change. Use a different file."
}
```

<Callout type="warn" title="Branch on usable, never the HTTP status">
  A flagged file still answers `200`. Submitting it later is refused, and retrying won't help.
</Callout>

### What finalize checks [#what-finalize-checks]

* The real file signature must match the declared kind. Renaming the file doesn't help.
* Images are decoded: max 16,384 px per side, max 40,000,000 px total. The decoded type must match the signature.
* Fail any check: the file is deleted and the reservation is spent. Reserve again.

### Finalize errors [#finalize-errors]

| Cause                                                                | Code                   | HTTP | Retryable |
| -------------------------------------------------------------------- | ---------------------- | ---- | --------- |
| Bytes not in storage yet, link still live                            | `input_not_ready`      | 409  | yes       |
| Bytes never arrived, link expired                                    | `invalid_config`       | 400  | **no**    |
| Another finalize is running for this file (holds it up to 2 minutes) | `rate_limited`         | 429  | yes       |
| Inspection failed, file could not be re-read                         | `provider_unavailable` | 502  | yes       |
| Unknown id, wrong prefix, other workspace, or deleted                | `not_found`            | 404  | no        |
| Real bytes don't match the declared type                             | `invalid_config`       | 400  | no        |
| Image over the size ceiling or outside pixel bounds                  | `invalid_config`       | 400  | no        |

## Using the asset id [#using-the-asset-id]

Put the id where the capability config asks for a file. The field name and shape come from `GET /capabilities/{capability_id}`. Never guess. [Capabilities API](/api/capabilities).

Tools take a plain array of ids:

```json title="Config for a tool"
{
  "source_video": ["ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011"],
  "caption_style": "punch"
}
```

Image and video models take objects, so a prompt can point at an alias:

```json title="Config for an image or video model"
{
  "prompt": "The bottle on a kitchen counter at golden hour, in the style of /image1",
  "reference_images": [
    {
      "assetId": "ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
      "alias": "image1",
      "role": "reference"
    }
  ]
}
```

* Asset ids must match `^ast_[0-9a-f]{32}$`. A made-up string like `"product-hero.png"` fails later, with a less clear error.
* A talking actor face goes on the submit body as `actor_image_asset_id`, not in `config`. [Generations API](/api/generations).
* **Finalize before you estimate.** Estimates for length-based capabilities read the measured `duration_ms`.

### Submit names a file that isn't ready [#submit-names-a-file-that-isnt-ready]

Default answer: `409 input_not_ready`, retryable. Wait a moment, don't resend at once. RiffAds checks the named files again first, and may answer with something final instead:

| File state                    | Answer                                  | Retryable |
| ----------------------------- | --------------------------------------- | --------- |
| Refused by content policy     | `422 moderation_blocked`, names the ids | no        |
| Not this workspace's, or gone | `404 not_found`, names the ids          | no        |

The blocked message reads: "One of the files in this request was refused by our content policy (ast\_...), so it can never be used in a generation. Sending this request again will be refused again. Upload a different file."

## Limits [#limits]

| Limit                         | Value                                     | At the edge                            |
| ----------------------------- | ----------------------------------------- | -------------------------------------- |
| Upload calls per minute       | 30 per key, reserve and finalize together | `429 rate_limited`, with `Retry-After` |
| Reservations per minute       | 30 per key, a second brake                | `429 rate_limited`                     |
| Pending uploads per workspace | 50, reservations from the last 15 minutes | `429 quota_exceeded`                   |
| Abandoned reservation         | Stops counting after 15 minutes           |                                        |
| Signed PUT lifetime           | 5 minutes, one use                        | Link stops working                     |

* Each key has its own rate bucket.
* The pending cap is workspace wide. Finish your reservations or wait 15 minutes.

## Not here [#not-here]

* **No list, no delete.** Lost an id? Upload again.
* **No multipart.** Bytes go to storage, never through `/api/v1`.
* **No browser calls.** It needs a secret key and sends no CORS headers. Call it from a server.

More: [limits](/reference/limits), [error codes](/reference/errors), [content policy](/policy/content).
