# Conventions (/api/conventions)



The rules every `/api/v1` endpoint follows. Learn them once.

## Success is bare [#success-is-bare]

Fields sit at the top level next to `ok: true`. No `data` wrapper.

```json title="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}`](/api/capabilities).

## Errors are wrapped [#errors-are-wrapped]

```json title="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`:

<TypeTable
  type="{
  ok: {
    type: 'false',
    required: true,
    description: 'Always false.',
  },
  code: {
    type: 'string',
    required: true,
    description: 'One of 25 fixed codes. Branch on this.',
  },
  message: {
    type: 'string',
    required: true,
    description: 'One readable sentence. Wording can change. Never branch on it.',
  },
  retryable: {
    type: 'boolean',
    required: true,
    description: 'false means never resend the same request.',
  },
  credits_charged: {
    type: 'number',
    required: true,
    description: 'What this failed call took. 0 for nearly every refusal. Never null here.',
  },
}"
/>

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](/reference/errors).

## HTTP status [#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 &#x2A;*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.

```json title="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 [#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 [#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.

```json title="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
  }
}
```

| 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 [#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 [#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     |

```bash title="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 [#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.

<Callout type="warn" title="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.
</Callout>

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

```json title="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 [#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.

```ts
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 [#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`, 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.

```json title="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 [#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 [#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](/reference/statuses-and-ids).

## Not in the API [#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.**
