Generations
Estimate, submit, read, wait on and batch-read a generation.
Read as MarkdownEvery endpoint that makes a video, image or audio file, and every field they take and return.
| Endpoint | Scope | Rate limit | What it does |
|---|---|---|---|
POST /estimates | Read | 60/min | Returns the number to put in max_credits. Starts nothing |
POST /generations | Generate | 20/min | Starts a job. 201 with Location |
GET /generations/{id} | Read | 120/min | Reads one generation. Answers now |
GET /generations/{id}/wait | Read | 120/min | Blocks up to 20 seconds, then says if done |
GET /batches/{id} | Read | 120/min | Reads every sibling of a variants submit |
There is no list endpoint and no cancel endpoint. Keep every generation_id you get.
Estimate
POST/api/v1/estimatesAPI key
Returns the number you need for max_credits. Starts nothing, charges nothing, runs no content check.
curl -s https://app.riffads.com/api/v1/estimates \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"capability_id": "tts",
"config": {
"script": "Three reasons this bottle keeps water cold all day."
}
}'{
"ok": true,
"capability_id": "tts",
"credits": 4,
"is_ceiling": false,
"breakdown": [
{ "label": "Tool", "unit": "second", "quantity": 4, "credits": 4 }
]
}Request body
Strict. Exactly two keys. A third is a 400.
Prop
Type
- No
max_credits,variants,actor_idorapproved_voice_generation_idhere. Those are submit fields. - Pricing several variants? Put
countinsideconfig. - Capabilities that work on a file (
auto_caption,change_voice,remove_background,resize,speech_to_speech,trim_video,upscale) need the upload id inconfig. Missing file:invalid_config. Uploads.
{
"capability_id": "auto_caption",
"config": {
"source_video": ["ast_9f2c41ab7d0e4c8fb35a6e1d2c904f77"]
}
}A good estimate does not mean a good submit
The submit refuses any config key the schema does not list. The estimate is not always that strict. Send only keys from config_schema.
Response
Prop
Type
breakdown[] field | Type | Notes |
|---|---|---|
label | string | Display text only |
unit | string | second, image, thousand_chars, million_tokens, request |
quantity | number | |
credits | integer |
Numbers change over time. Call the estimate before each submit. Don't cache it.
Estimate errors
| Code | HTTP | Retry | Cause |
|---|---|---|---|
rate_limited | 429 | yes | Over 60 a minute |
capability_unavailable | 404 | no | Unknown, retired or not live. Has capability_status |
required_plan | 403 | no | Not on this plan. Has required_plan |
invalid_config | 400 | no | Config fails the schema |
not_priced | 409 | no | Capability can't be used yet. Try another |
pricing_unavailable | 503 | yes | Our side. Retry shortly |
internal_error | 500 | yes | Our side |
Submit
POST/api/v1/generationsAPI key
Starts a job. Answers when the job is accepted, not done.
curl -s https://app.riffads.com/api/v1/generations \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"capability_id": "veo_31",
"config": { "prompt": "A steel bottle on a kitchen counter, morning light, slow push in." },
"max_credits": 440
}'Request body
Prop
Type
Sending the bare estimate fails
max_credits must be at least ceil(estimate * 1.1). Estimate 400 means send 440 or more. Less gets 402 max_credits_exceeded, nothing started. This is the most common first-integration failure.
- Unknown key, in the body or in
config:400 invalid_confignaming the key.maxCreditsis refused. - These fields don't exist and answer 400:
project_id,organization_id,quote,source,purpose,idempotency_key,workflow_run_id,pending_actor,webhook_url,callback_url. - Naming trap: body field is
voice_id, config field isvoiceId. variantsfills the capability's owncountfield and wins overcountinconfig. A capability with nocountrefusesvariants.
One submission at a time per key
A second POST /generations on the same key while the first is running gets 409 submission_in_flight. Wait on in_flight.generation_id, then submit.
{
"error": {
"ok": false,
"code": "submission_in_flight",
"message": "This API key already has a generation running (gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033, rendering). Wait for it to finish, or check on it with get_generation, before starting another one.",
"retryable": true,
"credits_charged": 0,
"in_flight": {
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"status": "rendering"
}
}
}variants: 4is one submit, one slot.- A stuck job frees the slot after 10 minutes.
- Jobs started in the app don't count.
- Need parallel jobs? Use a second key.
Response
{
"ok": true,
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"generation_ids": ["gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033"],
"run_id": "run_9f2c1b",
"replay": false,
"credits": {
"credits_held": 440,
"credits_charged": null,
"settlement": "open",
"terminal": false,
"wallet_balance": 4104
},
"capability_id": "veo_31",
"capability_name": "Veo 3.1",
"output_kind": "video",
"outputs_expected": 1,
"charge_summary": "440 credits are on hold. Nothing has been charged so far, and the final amount is not known until this finishes.",
"next_action": "GET /api/v1/generations/gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033/wait, and keep calling it until still_running is false. Do not hand the checking back to the person and do not ask them to prompt you again."
}HTTP/1.1 201 Created
Location: /api/v1/generations/gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033
Cache-Control: no-store| Field | Type | Always | Notes |
|---|---|---|---|
generation_id | string | yes | The one to wait on. With variants: the first sibling |
generation_ids | string[] | yes | Length 1 without variants |
run_id | string or null | yes | null is normal: queued, starting shortly |
batch_group_id | string | variants only | A bg_ id for GET /batches/{id} |
replay | boolean | yes | true: you got an already running job back. See conventions |
credits | object | yes | See the credits object |
capability_id | string | yes | Echoed |
capability_name | string | yes | Display name |
output_kind | image video audio text | yes | |
outputs_expected | integer | yes | How many files to expect |
charge_summary | string | yes | One readable sentence about credits |
next_action | string | yes | Advice for agents. Don't branch on it |
Submit errors
The first check that fails wins. Every refusal here has credits_charged: 0.
| Situation | Code | HTTP | Retry |
|---|---|---|---|
| Over 20 submits a minute on this key | rate_limited | 429 | yes |
| Key lacks Generate | insufficient_scope | 403 | no |
max_credits too low | max_credits_exceeded | 402 | no |
| A workspace or key spend cap blocked it (ask an owner) | spend_limit_exceeded | 402 | no |
| Not enough credits (top up in the app) | insufficient_credits | 402 | no |
| Unknown key, or config fails the schema | invalid_config | 400 | no |
| Capability retired or not live | capability_unavailable | 404 | no |
| Capability needs a higher plan | required_plan | 403 | no |
| Script, prompt or upload refused | moderation_blocked | 422 | no |
| Content checks down | moderation_unavailable | 503 | yes |
| Upload still scanning | input_not_ready | 409 | yes |
| Upload not in this workspace | not_found | 404 | no |
| Another job already running on this key | submission_in_flight | 409 | yes |
| Same request failed too often | request_blocked | 429 | no |
| Estimate moved since you checked | estimate_changed | 409 | yes |
| Capability can't be used yet | not_priced | 409 | no |
| Our side | pricing_unavailable | 503 | yes |
max_credits_exceededandspend_limit_exceededcarrylimit.bound_by, naming the cap that refused.- Every code: errors.
Read one generation
GET/api/v1/generations/{id}API key
Answers right away. Another workspace's id answers not_found.
{
"ok": true,
"generation": {
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"capability_id": "veo_31",
"status": "completed",
"batch_group_id": null,
"batch_index": null,
"outputs": [
{
"index": 0,
"kind": "video",
"url": "https://<signed-url>",
"file_name": "veo-31-0.mp4",
"width": 1080,
"height": 1920
}
],
"outputs_expected": 1,
"outputs_delivered": 1,
"credits": {
"credits_held": 440,
"credits_charged": 400,
"settlement": "captured",
"terminal": true,
"wallet_balance": 4104
},
"charge_summary": "Charged 400 credits.",
"credits_estimated": 400,
"error": null,
"created_at": "2026-09-17T10:04:11.482Z",
"completed_at": "2026-09-17T10:07:39.120Z"
},
"output_urls_expire_in_seconds": 600
}| Field | Type | Notes |
|---|---|---|
generation_id | string | gen_ prefix |
capability_id | string or null | null only on very old rows |
status | queued rendering post_processing completed failed | The full set. A canceled job reports failed |
batch_group_id | string or null | Variants only |
batch_index | integer or null | This sibling's position |
outputs[].index | integer | |
outputs[].kind | image video audio | |
outputs[].url | string or null | null until the job is completed and settled |
outputs[].file_name | string or null | |
outputs[].width | integer or null | |
outputs[].height | integer or null | |
outputs_expected | integer or null | Wait for this many, not outputs.length |
outputs_delivered | integer or null | |
credits | object | See below |
charge_summary | string | One readable sentence |
credits_estimated | integer | The estimate at submit |
error | object or null | { code, message }. Set only when status is failed |
created_at | ISO 8601 | |
completed_at | ISO 8601 or null | |
output_urls_expire_in_seconds | 600 | Top level. Only when some output has a link |
Links die in 600 seconds
Download the file right away. Don't store the URL. Read the generation again for a fresh link. Results.
The credits object
credits_charged is null until credits.terminal is true. null means not known yet, never zero.
Wait
GET/api/v1/generations/{id}/waitAPI key
The server watches the job for you. It answers when the job ends, or after about 18 seconds with still_running: true. Call it again in a loop.
GEN=gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033
while :; do
BODY=$(curl -s "https://app.riffads.com/api/v1/generations/$GEN/wait" \
-H "Authorization: Bearer $RIFFADS_API_KEY")
[ "$(echo "$BODY" | jq -r '.still_running')" = "false" ] && break
done
echo "$BODY" | jq -r '.generation.outputs[0].url'{
"ok": true,
"generation": { "generation_id": "gen_0193c8f0...", "status": "rendering", "outputs": [] },
"still_running": true,
"waited_seconds": 18,
"age_seconds": 41,
"retry_after_seconds": 0,
"next_action": "Still working. Call wait_for_generation again with the same generation_id right away, and keep calling it until still_running is false. Do not ask the person to prompt you again."
}| Field | Type | Always | Notes |
|---|---|---|---|
generation | object | yes | Same shape as GET /generations/{id} |
still_running | boolean | yes | Branch on this, not on status |
waited_seconds | integer | yes | How long this call blocked |
age_seconds | integer | yes | Seconds since the job was created. Log it and cap it |
retry_after_seconds | 0 | while running | Call straight back |
next_action | string | yes | Names MCP tools even over REST. Advice only |
output_urls_expire_in_seconds | 600 | when a link exists |
- Timeout answers
200, never408or504. - Each call costs 1 read from the 120 a minute bucket, however long it blocks.
- A finished job answers at once.
- No
timeoutparameter. - There is no published render time. Don't build a deadline from the wait.
- A wait loop uses far fewer requests than polling
GET /generations/{id}.
Read a batch
GET/api/v1/batches/{id}API key
A submit with variants makes several siblings. Watch only the first id and you will stop while the others still run. Pass the bg_ batch_group_id from the submit.
{
"ok": true,
"batch_group_id": "bg_0193c8f0a1b24e7f9d3c5a6b7e8f0099",
"status": "partial",
"variants": 4,
"variants_finished": 4,
"outputs_delivered": 3,
"credits": {
"credits_held": 168,
"credits_charged": 126,
"settlement": "captured",
"terminal": true
},
"charge_summary": "Charged 126 credits for the 3 of 4 outputs delivered.",
"generations": [
{ "generation_id": "gen_0193...a1", "batch_index": 0, "status": "completed" },
{ "generation_id": "gen_0193...a2", "batch_index": 1, "status": "completed" },
{ "generation_id": "gen_0193...a3", "batch_index": 2, "status": "completed" },
{ "generation_id": "gen_0193...a4", "batch_index": 3, "status": "failed" }
],
"next_action": "Part of this batch delivered and part failed. 3 files are ready, and you are charged only for what was delivered. Hand over what there is and say plainly which variants did not make it.",
"output_urls_expire_in_seconds": 600
}| Field | Type | Notes |
|---|---|---|
batch_group_id | string | Echoed |
status | running completed failed partial | |
variants | integer | Siblings in the batch |
variants_finished | integer | Siblings completed or failed |
outputs_delivered | integer | Summed |
credits | object | Rolled up. credits_charged stays null until every sibling is done |
charge_summary | string | One sentence for the batch |
generations | array | Each sibling, same shape as GET /generations/{id} |
next_action | string | |
output_urls_expire_in_seconds | 600 | When some sibling has a link |
While status is running: wait on one sibling with /wait, then read the batch again. Looping on GET /batches/{id} burns reads.
Talking actor is two submits
- Submit
ttswith the script and avoice_id. Wait for it. - Submit
actor_ultrawith the same script, samevoice_id, anactor_id, and the finished ttsgeneration_idasapproved_voice_generation_id.
{
"capability_id": "actor_ultra",
"config": { "script": "Three reasons this bottle keeps water cold all day." },
"actor_id": "act_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
"approved_voice_generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0044",
"max_credits": 160
}- Skip step 1:
400 invalid_config,Generate and approve the voice before creating the video. - Script, actor and voice must match across both calls.
- Voice is picked in order: body
voice_id, thenconfig.voiceId, then the actor's default. None:400 invalid_config. actor_ultrahas noaspect_ratio. Sending one is an unknown key. The video takes the actor image's shape.- The id is
actor_ultra.talking_actoris not a capability id. - The
actor_ultraestimate from a script is a ceiling (is_ceiling: true). Put an uploaded audio id inconfig.voice_audio(an array) for an exact one.
Full walkthrough: talking actor.