Uploads
Reserve, PUT the bytes within 5 minutes, finalize, then use the asset id.
Read as MarkdownSend 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.
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.
The three steps
Reserve an id and a URL
Send filename, content type and exact byte count. Measure the file first. Storage refuses any other size.
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
}'{
"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."
}PUT the bytes
Straight to storage. No API key. No RiffAds host.
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.pngFinalize and read usable
No body. Id in the path.
curl -s -X POST \
https://app.riffads.com/api/v1/uploads/ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011/finalize \
-H "Authorization: Bearer $RIFFADS_API_KEY"{
"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."
}- CLI:
riffads upload <file>does all three and prints the asset id. Commands. - MCP:
create_uploadandfinalize_upload, both in thespendset. Tools.
Reserve
POST/api/v1/uploadsAPI key
- Scope Generate.
- Answers
201, noLocationheader. - Strict body: an unknown key is
400 invalid_config.
Prop
Type
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
| 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 |
Only images are content-checked
Video and audio contents are never read. Don't tell a user those uploads were reviewed.
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_bytesbytes. - 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
POST/api/v1/uploads/{assetId}/finalizeAPI key
- Scope Generate.
- Answers
200. 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
{
"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."
}Branch on usable, never the HTTP status
A flagged file still answers 200. Submitting it later is refused, and retrying won't help.
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
| 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
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.
Tools take a plain array of ids:
{
"source_video": ["ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011"],
"caption_style": "punch"
}Image and video models take objects, so a prompt can point at an alias:
{
"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 inconfig. Generations API. - Finalize before you estimate. Estimates for length-based capabilities read the measured
duration_ms.
Submit names a file that isn't 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
| 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
- 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, error codes, content policy.