# Actors and voices (/api/library)



Find who speaks and in what voice. You get ids and facets, then pass the ids into a submit.

| Endpoint      | Returns                       | Pages |
| ------------- | ----------------------------- | ----- |
| `GET /actors` | Actors this workspace may use | yes   |
| `GET /voices` | Voices this workspace may use | yes   |

* Scope `read`. Every live key has it.
* Rate bucket `agent_read`: **120 calls a minute per key**, shared with other reads.

<Callout type="info" title="Brands and products have no API">
  Brands, products, new actors and new voices are made in the app at [app.riffads.com](https://app.riffads.com). There is no REST, MCP or CLI route to create or read them.
</Callout>

## Actors [#actors]

<Endpoint method="GET" path="/actors" />

The RiffAds cast plus the active actors your workspace made. Actors still processing, or disabled, are hidden.

| Parameter | Type    | Default | Notes                                                                                                       |
| --------- | ------- | ------- | ----------------------------------------------------------------------------------------------------------- |
| `search`  | string  | none    | Name **or** description, case insensitive. `%` and `_` are literal                                          |
| `limit`   | integer | 25      | Clamped to 1 to 50. A page holds at most 48, so 49 and 50 return 48. `limit=1000` gets a page, not an error |
| `cursor`  | string  | none    | The previous `next_cursor`, sent back exactly                                                               |

No other params. No gender, tag or age filter: match `gender`, `age_band` and `tags` yourself.

<Tabs items="['cURL','TypeScript']">
  <Tab value="cURL">
    ```bash title="Terminal"
    curl -sG https://app.riffads.com/api/v1/actors \
      -H "Authorization: Bearer $RIFFADS_API_KEY" \
      --data-urlencode "search=warm" \
      --data-urlencode "limit=50"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const BASE = "https://app.riffads.com/api/v1";

    async function allActors(key: string) {
      const actors = [];
      let cursor: string | null = null;

      do {
        const url = new URL(`${BASE}/actors`);
        url.searchParams.set("limit", "50");
        if (cursor) url.searchParams.set("cursor", cursor);

        const res = await fetch(url, {
          headers: { authorization: `Bearer ${key}` },
        });
        const body = await res.json();
        if (!res.ok) throw new Error(body.error.code);

        actors.push(...body.actors);
        cursor = body.next_cursor;
      } while (cursor);

      return actors;
    }
    ```
  </Tab>
</Tabs>

```json title="Response: 200"
{
  "ok": true,
  "actors": [
    {
      "actor_id": "act_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
      "name": "Maya",
      "description": "Warm, direct, speaks to camera at home",
      "gender": "female",
      "age_band": "adult",
      "tags": ["kitchen", "morning", "standing", "casual"],
      "default_voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
      "default_voice_name": "Maya (natural)",
      "is_platform": true
    }
  ],
  "next_cursor": "eyJpc09yZyI6ZmFsc2UsImlzQ29yZSI6dHJ1ZSwiY3JlYXRlZEF0IjoiMjAyNi0wOS0wMVQxMDoxMjowMFoiLCJpZCI6ImFjdF8wMTkzIn0"
}
```

<TypeTable
  type="{
  actor_id: {
    type: 'string',
    required: true,
    description: 'Pass into a submit. Prefix act_.',
  },
  name: {
    type: 'string',
    required: true,
    description: 'Display name. Not unique: match on the id.',
  },
  description: {
    type: 'string | null',
    required: true,
    description: 'One line of casting copy, or null. Matched by search.',
  },
  gender: {
    type: 'string | null',
    required: true,
    description: 'female, male, nonbinary, or null.',
  },
  age_band: {
    type: 'string | null',
    required: true,
    description: 'kid, young_adult, adult, senior, or null.',
  },
  tags: {
    type: 'string[]',
    required: true,
    description: 'snake_case situation tags, like kitchen or post_workout. Never null.',
  },
  default_voice_id: {
    type: 'string | null',
    required: true,
    description: 'The voc_ id this actor normally uses, or null.',
  },
  default_voice_name: {
    type: 'string | null',
    required: true,
    description: 'Display name of that voice.',
  },
  is_platform: {
    type: 'boolean',
    required: true,
    description: 'true for a RiffAds cast actor, false for your own.',
  },
}"
/>

* **Order:** your actors first, then the core cast, then the rest. Newest first in each group.
* **No `total`.** The body has only `ok`, `actors` and `next_cursor`. Page until `next_cursor` is `null`.
* **Plan gated.** Higher plans see more of the cast. Your own actors are always listed.

<Boundary title="No faces over the API">
  No preview image or clip comes back, ever. You get the id and the facets. To see faces, open [app.riffads.com](https://app.riffads.com).
</Boundary>

## Voices [#voices]

<Endpoint method="GET" path="/voices" />

The RiffAds voice library plus voices your workspace cloned or designed. Active only, sorted by name.

| Parameter  | Type    | Default | Notes                                                  |
| ---------- | ------- | ------- | ------------------------------------------------------ |
| `search`   | string  | none    | **Name only**, case insensitive. Tags are not searched |
| `language` | string  | none    | Exact tag, like `en` or `es`. Not a prefix             |
| `limit`    | integer | 25      | Clamped to 1 to 50                                     |
| `cursor`   | string  | none    | The previous `next_cursor`, sent back exactly          |

```bash title="Terminal"
curl -sG https://app.riffads.com/api/v1/voices \
  -H "Authorization: Bearer $RIFFADS_API_KEY" \
  --data-urlencode "language=en"
```

```json title="Response: 200"
{
  "ok": true,
  "voices": [
    {
      "voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
      "name": "Maya (natural)",
      "language": "en",
      "gender": "female",
      "tags": ["young_adult", "american", "conversational"],
      "is_cloned": false,
      "is_premium": false,
      "is_platform": true
    }
  ],
  "total": 61,
  "next_cursor": "25"
}
```

<TypeTable
  type="{
  voice_id: {
    type: 'string',
    required: true,
    description: 'Pass into a submit. Prefix voc_.',
  },
  name: {
    type: 'string',
    required: true,
    description: 'Display name. The only field search matches.',
  },
  language: {
    type: 'string | null',
    required: true,
    description: 'Language tag, mostly ISO 639-1. Matched exactly.',
  },
  gender: {
    type: 'string | null',
    required: true,
    description: 'female, male, nonbinary, or null.',
  },
  tags: {
    type: 'string[]',
    required: true,
    description: 'snake_case age, accent, energy and use tags, like young_adult, british, authoritative. Never null.',
  },
  is_cloned: {
    type: 'boolean',
    required: true,
    description: 'true when cloned from a recording.',
  },
  is_premium: {
    type: 'boolean',
    required: true,
    description: 'true for a voice RiffAds marks premium.',
  },
  is_platform: {
    type: 'boolean',
    required: true,
    description: 'true for a RiffAds library voice, false for your own.',
  },
}"
/>

* `total` counts matches **before** paging. `GET /actors` has no `total`.
* No preview audio comes back.
* **Cloned voices have no language and no gender.** A `language` filter misses them. Find them by name.

<Callout type="info" title="The two cursors differ. Treat both as opaque.">
  The actor cursor is a base64url keyset. The voice cursor is an offset as a string. Send `next_cursor` back exactly, never build one. A bad cursor restarts from the top, not an empty page. [Conventions](/api/conventions).
</Callout>

## From ids to a submit [#from-ids-to-a-submit]

`actor_id` and `voice_id` go at the **top level** of the submit body, not in `config`:

```json title="Request"
{
  "capability_id": "actor_ultra",
  "actor_id": "act_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
  "voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
  "approved_voice_generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0044",
  "config": { "script": "Two weeks in, and I am not going back." },
  "max_credits": 660
}
```

1. **A talking actor is 2 generations.** First `tts`, then `actor_ultra` with the `tts` `generation_id` as `approved_voice_generation_id`. Script, actor and voice must match the `tts` job. [Talking actor guide](/guides/talking-actor).
2. **`voice_id` is optional** when the actor has a `default_voice_id`.
3. **Body `voice_id` and config `voiceId` are different layers.** Read `config_schema` from [`GET /capabilities/{capability_id}`](/api/capabilities).
4. **Another workspace's id answers `404 not_found`**, same as a missing id. Never `forbidden`.

## Errors [#errors]

Standard error envelope. See [conventions](/api/conventions).

| Code                    | HTTP | Retryable | Cause                                                                                 |
| ----------------------- | ---- | --------- | ------------------------------------------------------------------------------------- |
| `not_authorized`        | 401  | no        | No key, revoked, or unknown                                                           |
| `rate_limited`          | 429  | yes       | Over 120 `agent_read` calls a minute. Carries `retry_after_seconds` and `Retry-After` |
| `quota_exceeded`        | 429  | no        | Key used its request allowance. Waiting a bit won't help                              |
| `required_plan`         | 403  | no        | Plan doesn't include the agent API                                                    |
| `workspace_unavailable` | 410  | no        | Workspace deleted                                                                     |
| `internal_error`        | 500  | yes       | Our fault. Try again                                                                  |

A balance `internal_error` says so, so it never reads as an empty wallet:

```json title="Response: 500"
{
  "error": {
    "ok": false,
    "code": "internal_error",
    "message": "We could not read this workspace's credit balance. This is not a balance problem: try again.",
    "retryable": true,
    "credits_charged": 0
  }
}
```

MCP: `list_actors` and `list_voices` take the same params and return the same shape. [MCP tools](/mcp/tools).
