Conventions
Response shapes, errors, headers, bodies, pagination, replays, retries and ids.
Read as MarkdownThe rules every /api/v1 endpoint follows. Learn them once.
Success is bare
Fields sit at the top level next to ok: true. No data wrapper.
{
"ok": true,
"actors": [
{
"actor_id": "act_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"name": "Maya",
"gender": "female",
"default_voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
"is_platform": true
}
],
"next_cursor": "eyJpc09yZyI6dHJ1ZSwiaXNDb3JlIjpmYWxzZSwiY3JlYXRlZEF0IjoiMjAyNi0wOS0wMVQxMDoxMjowMFoiLCJpZCI6ImFjdF8wMTkzIn0"
}- Wire fields are snake_case.
- Exception: fields inside
configcome from the capability schema and can be camelCase (voiceId). Readconfig_schemafromGET /capabilities/{id}.
Errors are wrapped
{
"error": {
"ok": false,
"code": "insufficient_credits",
"message": "Not enough credits for this request.",
"retryable": false,
"credits_charged": 0,
"shortfall": 240
}
}Always inside error:
Prop
Type
Extra fields, only when they apply:
| Extra | Type | Appears on |
|---|---|---|
shortfall | number | insufficient_credits |
retry_after_seconds | number | rate_limited, a temporary request_blocked, submission_in_flight for an identical request |
limit | object | max_credits_exceeded, spend_limit_exceeded. limit.bound_by names the cap that refused |
in_flight | object | submission_in_flight, with generation_id and status |
blocked_reason | string | request_blocked: the code that caused the block |
capability_status | string | capability_unavailable, and required_plan from the capability check |
required_plan | string | required_plan: the plan that includes it |
No errors array, no details object. Every code: errors.
HTTP status
Status comes from the code, same everywhere. The ones that surprise people:
workspace_unavailableis 410, not 403.moderation_blockedis 422.insufficient_scopeis 403, not 401.- Three codes are 429:
rate_limited(retry),quota_exceeded(don't),request_blocked(don't). - Another workspace's id answers 404
not_found, never forbidden. - Unknown
/api/v1paths answer404 not_foundin the envelope, with no key check. A wrong method on a real path is a plain405, not the envelope.
{
"error": {
"ok": false,
"code": "not_found",
"message": "GET /api/v1/generation/gen_123 is not a RiffAds API endpoint. See /api/v1/capabilities to start, or check the docs for the current path list.",
"retryable": false,
"credits_charged": 0
}
}Response headers
| Header | When | Value |
|---|---|---|
Cache-Control | Every response | no-store |
Content-Type | Every response | application/json |
Retry-After | A 429 with retry_after_seconds | Same integer as the body |
WWW-Authenticate | Every 401 | Bearer realm="RiffAds" |
Location | The 201 from POST /generations and both invoke routes | /api/v1/generations/{id} or /api/v1/workflow-runs/{id} |
That is the full list. No X-RateLimit-*, no request id, no version header. POST /uploads returns 201 without Location.
Request bodies are strict
Send a JSON object with Content-Type: application/json.
An unknown key is refused, never ignored. Inside config too. maxCredits instead of max_credits is a 400.
{
"error": {
"ok": false,
"code": "invalid_config",
"message": "That request body is not valid. max_credits: Invalid input: expected number, received undefined; Unrecognized key: \"maxCredits\".",
"retryable": false,
"credits_charged": 0
}
}| Cause | Message |
|---|---|
| Not JSON | That request body is not valid JSON. Send a JSON object and set Content-Type: application/json. |
null, array or scalar | That request body is not a JSON object. Send an object with the documented fields at its top level. |
| Fails the schema | That request body is not valid. {path}: {issue}; {path}: {issue}. Up to 5 issues, then Plus N more. |
Refused in any body: project_id, organization_id, idempotency_key, source, quote, and any webhook or callback field. Invoke bodies also refuse workflow_id and template_key.
Query params are lenient
A param that can't be read counts as omitted, not an error.
| You send | Server reads |
|---|---|
?limit=25 | 25 |
?limit=abc, ?limit=, ?limit=0, ?limit=-3, ?limit=2.5 | omitted, default applies |
?limit=1000 | clamped to the max |
?search=%20%20 | omitted |
?search=%20maya%20 | maya |
Pagination
Cursor only. No page, offset, per_page or after.
| Endpoint | limit default | limit max | total |
|---|---|---|---|
GET /capabilities | 25 | 50 | yes |
GET /actors | 25 | 50 | no |
GET /voices | 25 | 50 | yes |
curl -sG https://app.riffads.com/api/v1/capabilities \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
--data-urlencode "limit=50" \
--data-urlencode "cursor=auto_caption"- Pass
next_cursorback ascursor, exactly. Don't parse or build one. next_cursorisnullon the last page. Stop there.- A stale or bad cursor restarts from page one. It never returns an empty page.
totalcounts rows this workspace can see, after filters.
No paging, returns everything: GET /workflows/templates, GET /batches/{id}, GET /workflow-runs/{id}.
Idempotency and replays
There is no Idempotency-Key header and no idempotency_key field. Sending the field is a 400. The server makes its own key per request.
Two identical submits can mean two jobs
Resending a submit after the first job finished starts a new, paid job. Lost the response? Read the generation. Never resubmit to find out.
A submit that matches a job still running in this workspace does not start a second one. You get the existing job back with replay: true.
{
"ok": true,
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"generation_ids": ["gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033"],
"replay": true,
"next_action": "This exact request was already running in this workspace, so RiffAds handed back the generation that is already going rather than starting and charging for a second one. 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."
}- A replay is still
201withLocation. Readreplayin the body. - Only while the first job is
queued,renderingorpost_processing. - Never for submits with
variantsabove 1. - Identical request running for someone else in the workspace:
409 submission_in_flightwithretry_after_seconds: 30instead.
Retries
retryable is the contract.
false: never send the same request again. Change it or stop.true: retry a few times. Sleepretry_after_secondswhen present, else back off. Then stop.
if (!body.error.retryable) {
// Change the request or stop. Do not resend this one.
throw new Error(`${body.error.code}: ${body.error.message}`);
}
const waitSeconds = body.error.retry_after_seconds ?? backoffSeconds;Blocked requests
Repeated failures of the same exact request put it on a block list. The block is keyed on the request (workspace, capability, actor, voice, uploads, config), not on you.
| Block | Armed after | Lasts |
|---|---|---|
| Temporary | 3 failures from provider_unavailable, internal_error, or some content refusals | about 1 hour |
| Permanent | 2 content refusals | 30 days |
- Blocked:
429 request_blocked,retryable: false, withblocked_reason. - Temporary blocks carry
retry_after_seconds. Permanent ones don't. - Adding junk fields won't escape a block. Change the script or inputs.
- A successful submit clears the flags for that request.
- Money refusals never arm a block.
{
"error": {
"ok": false,
"code": "request_blocked",
"message": "This exact request was refused by content review 2 times, so RiffAds will not run it again. Change the script or the inputs and send a new request. Resending this one unchanged will be refused without being checked.",
"retryable": false,
"credits_charged": 0,
"blocked_reason": "moderation_blocked"
}
}next_action is advice
Submits, waits, uploads and invokes return next_action: a sentence written for an AI agent. Don't branch code on it. Branch on still_running, status and replay.
Some next_action sentences name MCP tools (wait_for_generation, get_generation, finalize_upload) even over REST. Read those as the matching REST call.
Ids and timestamps
| Prefix | Thing |
|---|---|
gen_ | Generation |
bg_ | Batch group |
act_ | Actor |
voc_ | Voice |
ast_ | Upload |
sk_live_ | API key |
- Timestamps are ISO 8601 UTC:
2026-09-17T10:04:11.482Z. - Statuses: statuses and ids.
Not in the API
- No outbound webhooks. A webhook or callback field is a 400. Use the wait endpoint.
- No SSE or websockets.
- No OpenAPI file, no SDK. These pages are the spec.
- No test keys.
sk_live_only. - No generation list, no cancel, no key management, no workflow authoring.