REST API

Conventions

Response shapes, errors, headers, bodies, pagination, replays, retries and ids.

Read as Markdown

The 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.

Response: 200 (trimmed)
{
  "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 config come from the capability schema and can be camelCase (voiceId). Read config_schema from GET /capabilities/{id}.

Errors are wrapped

Response: 402
{
  "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:

ExtraTypeAppears on
shortfallnumberinsufficient_credits
retry_after_secondsnumberrate_limited, a temporary request_blocked, submission_in_flight for an identical request
limitobjectmax_credits_exceeded, spend_limit_exceeded. limit.bound_by names the cap that refused
in_flightobjectsubmission_in_flight, with generation_id and status
blocked_reasonstringrequest_blocked: the code that caused the block
capability_statusstringcapability_unavailable, and required_plan from the capability check
required_planstringrequired_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_unavailable is 410, not 403.
  • moderation_blocked is 422.
  • insufficient_scope is 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/v1 paths answer 404 not_found in the envelope, with no key check. A wrong method on a real path is a plain 405, not the envelope.
Response: 404
{
  "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

HeaderWhenValue
Cache-ControlEvery responseno-store
Content-TypeEvery responseapplication/json
Retry-AfterA 429 with retry_after_secondsSame integer as the body
WWW-AuthenticateEvery 401Bearer realm="RiffAds"
LocationThe 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.

Response: 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
  }
}
CauseMessage
Not JSONThat request body is not valid JSON. Send a JSON object and set Content-Type: application/json.
null, array or scalarThat request body is not a JSON object. Send an object with the documented fields at its top level.
Fails the schemaThat 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 sendServer reads
?limit=2525
?limit=abc, ?limit=, ?limit=0, ?limit=-3, ?limit=2.5omitted, default applies
?limit=1000clamped to the max
?search=%20%20omitted
?search=%20maya%20maya

Pagination

Cursor only. No page, offset, per_page or after.

Endpointlimit defaultlimit maxtotal
GET /capabilities2550yes
GET /actors2550no
GET /voices2550yes
Terminal
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_cursor back as cursor, exactly. Don't parse or build one.
  • next_cursor is null on the last page. Stop there.
  • A stale or bad cursor restarts from page one. It never returns an empty page.
  • total counts 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.

Response: 201 (trimmed)
{
  "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 201 with Location. Read replay in the body.
  • Only while the first job is queued, rendering or post_processing.
  • Never for submits with variants above 1.
  • Identical request running for someone else in the workspace: 409 submission_in_flight with retry_after_seconds: 30 instead.

Retries

retryable is the contract.

  • false: never send the same request again. Change it or stop.
  • true: retry a few times. Sleep retry_after_seconds when 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.

BlockArmed afterLasts
Temporary3 failures from provider_unavailable, internal_error, or some content refusalsabout 1 hour
Permanent2 content refusals30 days
  • Blocked: 429 request_blocked, retryable: false, with blocked_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.
Response: 429
{
  "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

PrefixThing
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.

On this page