REST API

Uploads

Reserve, PUT the bytes within 5 minutes, finalize, then use the asset id.

Read as Markdown

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.

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.

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
  }'
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."
}

PUT the bytes

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

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

Finalize and read usable

No body. Id in the path.

Terminal
curl -s -X POST \
  https://app.riffads.com/api/v1/uploads/ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011/finalize \
  -H "Authorization: Bearer $RIFFADS_API_KEY"
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."
}
  • CLI: riffads upload <file> does all three and prints the asset id. Commands.
  • MCP: create_upload and finalize_upload, both in the spend set. Tools.

Reserve

POST/api/v1/uploadsAPI key
  • Scope Generate.
  • Answers 201, no Location header.
  • Strict body: an unknown key is 400 invalid_config.

Prop

Type

Accepted types and sizes

KindContent typesMaximum bytes
imageimage/jpeg, image/png, image/webp, image/gif20 MB (20971520)
videovideo/mp4, video/webm, video/quicktime100 MB (104857600)
audioaudio/mpeg, audio/mp3, audio/wav, audio/x-wav, audio/webm, audio/ogg25 MB (26214400)

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

Response fields

FieldTypeNotes
asset_idstringast_ plus 32 lowercase hex characters
upload_urlstringSigned PUT. One use, one file, one type, one exact length
upload_expires_atstringISO 8601
upload_expires_in_secondsinteger300
kindimage | video | audioFrom content_type
content_typestringEchoed
size_bytesintegerEchoed
max_bytes_for_kindintegerCeiling for this kind
content_checkedbooleantrue for images only
next_actionstringThe 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_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

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.
FieldTypeNotes
asset_idstringEchoed
scan_statusclean | flagged | pendingpending is not reachable today
usablebooleantrue only when scan_status is clean
kindimage | video | audioMeasured from the bytes, not your declaration
duration_msinteger | nullnull for images, or when the file can't be probed
next_actionstringOne sentence per scan status

A refused file is a 200

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."
}

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

CauseCodeHTTPRetryable
Bytes not in storage yet, link still liveinput_not_ready409yes
Bytes never arrived, link expiredinvalid_config400no
Another finalize is running for this file (holds it up to 2 minutes)rate_limited429yes
Inspection failed, file could not be re-readprovider_unavailable502yes
Unknown id, wrong prefix, other workspace, or deletednot_found404no
Real bytes don't match the declared typeinvalid_config400no
Image over the size ceiling or outside pixel boundsinvalid_config400no

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:

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:

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.
  • 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 stateAnswerRetryable
Refused by content policy422 moderation_blocked, names the idsno
Not this workspace's, or gone404 not_found, names the idsno

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

LimitValueAt the edge
Upload calls per minute30 per key, reserve and finalize together429 rate_limited, with Retry-After
Reservations per minute30 per key, a second brake429 rate_limited
Pending uploads per workspace50, reservations from the last 15 minutes429 quota_exceeded
Abandoned reservationStops counting after 15 minutes
Signed PUT lifetime5 minutes, one useLink 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.

On this page