# Generations (/api/generations)



Every 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 &#x2A;*no list endpoint and no cancel endpoint.** Keep every `generation_id` you get.

## Estimate [#estimate]

<Endpoint method="POST" path="/estimates" />

Returns the number you need for `max_credits`. Starts nothing, charges nothing, runs no content check.

```bash title="Terminal"
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."
    }
  }'
```

```json title="Response: 200"
{
  "ok": true,
  "capability_id": "tts",
  "credits": 4,
  "is_ceiling": false,
  "breakdown": [
    { "label": "Tool", "unit": "second", "quantity": 4, "credits": 4 }
  ]
}
```

### Request body [#request-body]

Strict. Exactly two keys. A third is a 400.

<TypeTable
  type="{
  capability_id: {
    type: 'string',
    required: true,
    description: 'Capability id, like &#x22;tts&#x22; or &#x22;actor_ultra&#x22;.',
  },
  config: {
    type: 'object',
    required: true,
    description: 'The config exactly as you will submit it. Shape from GET /capabilities/{id}.',
  },
}"
/>

* No `max_credits`, `variants`, `actor_id` or `approved_voice_generation_id` here. Those are submit fields.
* Pricing several variants? Put `count` inside `config`.
* Capabilities that work on a file (`auto_caption`, `change_voice`, `remove_background`, `resize`, `speech_to_speech`, `trim_video`, `upscale`) need the upload id in `config`. Missing file: `invalid_config`. [Uploads](/api/uploads).

```json title="Request: file-based capability"
{
  "capability_id": "auto_caption",
  "config": {
    "source_video": ["ast_9f2c41ab7d0e4c8fb35a6e1d2c904f77"]
  }
}
```

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

### Response [#response]

<TypeTable
  type="{
  ok: { type: 'true', required: true, description: 'Always true on success.' },
  capability_id: { type: 'string', required: true, description: 'Echoed back.' },
  credits: { type: 'number', required: true, description: 'The estimate. Use it to size max_credits.' },
  is_ceiling: { type: 'boolean', required: true, description: 'True when credits is an upper bound, not an exact number (a talking actor priced from a script).' },
  breakdown: { type: 'array', required: true, description: 'One line per part. May be empty.' },
  seconds: { type: 'number', description: 'Only when the capability has a duration.' },
  note: { type: 'string', description: 'Only when the number needs explaining.' },
}"
/>

| `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 [#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 [#submit]

<Endpoint method="POST" path="/generations" />

Starts a job. Answers when the job is **accepted**, not done.

```bash title="Terminal"
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 [#request-body-1]

<TypeTable
  type="{
  capability_id: {
    type: 'string',
    required: true,
    description: 'Id from GET /capabilities.',
  },
  config: {
    type: 'object',
    required: true,
    description: 'Checked strictly against the schema from GET /capabilities/{id}.',
  },
  max_credits: {
    type: 'integer',
    required: true,
    description: 'Spend cap for this whole request, all variants included. Whole number above zero. Send at least ceil(estimate * 1.1).',
  },
  variants: {
    type: 'integer',
    required: false,
    description: 'Make N sibling outputs in one request. Max min(capability max, 4). Out of range is refused, never clamped.',
  },
  actor_id: {
    type: 'string',
    required: false,
    description: 'An act_ id from GET /actors.',
  },
  actor_image_asset_id: {
    type: 'string',
    required: false,
    description: 'An ast_ upload id. The face when the actor is not from the library.',
  },
  voice_id: {
    type: 'string',
    required: false,
    description: 'A voc_ id from GET /voices. Wins over config.voiceId.',
  },
  approved_voice_generation_id: {
    type: 'string',
    required: false,
    description: 'The finished tts generation whose audio drives an actor_ultra video.',
  },
}"
/>

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

* Unknown key, in the body or in `config`: `400 invalid_config` naming the key. `maxCredits` is 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 is `voiceId`.
* `variants` fills the capability's own `count` field and wins over `count` in `config`. A capability with no `count` refuses `variants`.

### One submission at a time per key [#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.

```json title="Response: 409"
{
  "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: 4` is 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 [#response-1]

```json title="Response: 201 Created"
{
  "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."
}
```

```text title="Response headers"
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](/api/conventions) |
| `credits`          | object                         | yes           | See [the credits object](#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 [#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_exceeded` and `spend_limit_exceeded` carry `limit.bound_by`, naming the cap that refused.
* Every code: [errors](/reference/errors).

## Read one generation [#read-one-generation]

<Endpoint method="GET" path="/generations/{id}" />

Answers right away. Another workspace's id answers `not_found`.

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

<Callout type="warn" title="Links die in 600 seconds">
  Download the file right away. Don't store the URL. Read the generation again for a fresh link. [Results](/guides/results).
</Callout>

### The credits object [#the-credits-object]

`credits_charged` is `null` until `credits.terminal` is `true`. `null` means not known yet, never zero.

## Wait [#wait]

<Endpoint method="GET" path="/generations/{id}/wait" />

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.

<Tabs items="['cURL','TypeScript']">
  <Tab value="cURL">
    ```bash title="Terminal"
    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'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const BASE = "https://app.riffads.com/api/v1";
    const headers = { Authorization: `Bearer ${process.env.RIFFADS_API_KEY}` };

    async function waitForGeneration(generationId: string, maxAgeSeconds = 1800) {
        for (;;) {
            const res = await fetch(`${BASE}/generations/${generationId}/wait`, { headers });
            const body = await res.json();
            if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
            if (!body.still_running) return body.generation;
            if (body.age_seconds > maxAgeSeconds) {
                throw new Error(`${generationId} still running after ${body.age_seconds}s`);
            }
            // retry_after_seconds is 0: call straight back.
        }
    }
    ```
  </Tab>
</Tabs>

```json title="Response: 200, still running (generation trimmed)"
{
  "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`, never `408` or `504`.
* Each call costs 1 read from the 120 a minute bucket, however long it blocks.
* A finished job answers at once.
* No `timeout` parameter.
* 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 [#read-a-batch]

<Endpoint method="GET" path="/batches/{id}" />

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.

```json title="Response: 200 (siblings trimmed)"
{
  "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 [#talking-actor-is-two-submits]

1. Submit `tts` with the script and a `voice_id`. Wait for it.
2. Submit `actor_ultra` with the same script, same `voice_id`, an `actor_id`, and the finished tts `generation_id` as `approved_voice_generation_id`.

```json title="Request: step 2"
{
  "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`, then `config.voiceId`, then the actor's default. None: `400 invalid_config`.
* `actor_ultra` has &#x2A;*no `aspect_ratio`**. Sending one is an unknown key. The video takes the actor image's shape.
* The id is `actor_ultra`. `talking_actor` is not a capability id.
* The `actor_ultra` estimate from a script is a ceiling (`is_ceiling: true`). Put an uploaded audio id in `config.voice_audio` (an array) for an exact one.

Full walkthrough: [talking actor](/guides/talking-actor).
