---
title: "Custom voices"
description: "Request access, clone a voice, track its lifecycle, and use it on an agent."
---

Custom voices let your organization create a reusable agent voice from a
short audio sample. Creation is asynchronous: a new clone starts in `queued`,
moves to `processing`, and finishes as either `ready` or `failed`.

<Note>
  Every endpoint on this page requires
  `Authorization: Bearer sk_live_YOUR_API_KEY`, except a signed preview URL.
  Resources are scoped to the organization associated with the credential.
</Note>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/voices/policy` | Check access and ThunderPhone quota |
| `POST` | `/v1/voices/quota-requests` | Request access or a larger quota |
| `GET` | `/v1/voices/quota-requests/current` | Retrieve the latest quota request |
| `GET` | `/v1/voices/clones` | List custom voices |
| `POST` | `/v1/voices/clones` | Create a custom voice |
| `GET` | `/v1/voices/clones/{public_id}` | Retrieve a custom voice |
| `PATCH` | `/v1/voices/clones/{public_id}` | Rename a custom voice or set its gender |
| `DELETE` | `/v1/voices/clones/{public_id}` | Delete a custom voice |
| `GET` | `/v1/voices/clones/{public_id}/preview` | Stream the ready voice's MP3 preview |

## Custom voice object

```json
{
  "name": "custom:cv_2f6f90b0e9a34ee8b39be7d1",
  "public_id": "cv_2f6f90b0e9a34ee8b39be7d1",
  "display_name": "Support voice",
  "language": "en",
  "gender": "female",
  "status": "ready",
  "failure_reason": "",
  "created_at": "2026-07-30T14:12:08.317Z",
  "sample_url": "https://api.thunderphone.com/v1/voices/clones/cv_2f6f90b0e9a34ee8b39be7d1/preview?sig=..."
}
```

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Agent voice value in the form `custom:<public_id>` |
| `public_id` | string | Public identifier used in clone endpoints |
| `display_name` | string | Organization-facing name, 1–100 characters |
| `language` | string | The clone's single language code |
| `gender` | string or null | `male`, `female`, or `null` when the clone was created without one. Set it with `PATCH` |
| `status` | string | `queued`, `processing`, `ready`, or `failed` |
| `failure_reason` | string | Processing failure detail; empty unless the clone failed |
| `created_at` | timestamp | ISO 8601 creation time |
| `sample_url` | string \| null | Signed preview URL when a preview is available; otherwise `null` |

## Request access and quota

### Check policy

Check whether cloning is enabled and how many of your organization's
ThunderPhone custom voice slots are in use.

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/voices/policy \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response (200 OK)
{
  "cloning_enabled": true,
  "max_custom_voices": 5,
  "used": 2,
  "custom_voice_surcharge_cents": 1
}
```

| Field | Type | Description |
|-------|------|-------------|
| `cloning_enabled` | boolean | Whether the organization can create custom voices |
| `max_custom_voices` | integer | ThunderPhone custom voice limit for the organization |
| `used` | integer | Non-deleted custom voices currently using that limit |
| `custom_voice_surcharge_cents` | integer | Additional cents per minute when a custom voice is used |

### Submit a quota request

Use the same flow to request initial access or a larger ThunderPhone limit.
Only one request can be pending for an organization at a time.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voices/quota-requests \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "requested_count": 5,
    "note": "Voices for our support teams"
  }'
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `requested_count` | integer | yes | Requested ThunderPhone custom voice limit; at least 1 |
| `note` | string | no | Context for the request, up to 2,000 characters |

```json Response (201 Created)
{
  "status": "pending",
  "requested_count": 5,
  "created_at": "2026-07-30T13:55:42.104Z"
}
```

| Status | Response | When |
|--------|----------|------|
| `400` | `{"requested_count":"Must be an integer."}` | Count is absent or not an integer |
| `400` | `{"requested_count":"Must be at least 1."}` | Count is less than 1 |
| `400` | `{"note":"Must be 2000 characters or fewer."}` | Note is too long |
| `409` | `{"detail":"A quota request is already pending."}` | The organization already has a pending request |

### Check the latest request

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/voices/quota-requests/current \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response (200 OK)
{
  "status": "approved",
  "requested_count": 5,
  "created_at": "2026-07-30T13:55:42.104Z",
  "resolved_at": "2026-07-30T14:03:19.880Z"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `status` | string | `pending`, `approved`, or `denied` |
| `requested_count` | integer | Requested ThunderPhone custom voice limit |
| `created_at` | timestamp | ISO 8601 submission time |
| `resolved_at` | timestamp \| null | ISO 8601 decision time, or `null` while pending |

Returns `404 Not Found` when the organization has never submitted a request.

## Create a custom voice

Upload one WAV or MP3 file as `multipart/form-data`. The sample must contain
3–15 seconds of speech and be no larger than 4 MB. Use one language per
clone, and only upload a voice you have the rights and consent to clone.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/voices/clones \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -F "audio=@support-voice.wav;type=audio/wav" \
  -F "name=Support voice" \
  -F "language=en" \
  -F "gender=female" \
  -F "consent=true"
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `audio` | file | yes | WAV or MP3, no larger than 4 MB, containing 3–15 seconds of speech |
| `name` | string | yes | Display name, 1–100 characters |
| `language` | string | yes | One of `ar`, `zh`, `nl`, `en`, `fr`, `de`, `he`, `hi`, `it`, `ja`, `ko`, `pl`, `pt`, `ru`, or `es` |
| `consent` | boolean | yes | Must be exactly `true` to confirm the right and consent to clone the voice |
| `gender` | string | no | `male` or `female`. ThunderPhone tells the agent's language models which gender the voice has so replies use the matching first-person forms in languages that mark the speaker's gender (Russian, Polish, Hebrew, Hindi, Arabic, ...). Omit it and the agent guesses in those languages; English and other ungendered first-person languages are unaffected. You can add it later with `PATCH` |

```json Response (202 Accepted)
{
  "name": "custom:cv_2f6f90b0e9a34ee8b39be7d1",
  "public_id": "cv_2f6f90b0e9a34ee8b39be7d1",
  "display_name": "Support voice",
  "language": "en",
  "gender": "female",
  "status": "queued",
  "failure_reason": "",
  "created_at": "2026-07-30T14:12:08.317Z",
  "sample_url": null,
  "warnings": []
}
```

The `202 Accepted` response means the upload was queued, not that the voice is
ready. Poll the clone detail until it reaches a terminal status, or subscribe
to [`voice.ready` and `voice.failed`](/webhooks/events#voice-events) for push
notifications.

`warnings` lists non-blocking problems with the request. It is empty unless
`gender` was omitted (or sent empty) for a language whose first-person verb
forms change with the speaker's gender (`ru`, `pl`, `he`, `hi`, `ar`):

```json
"warnings": [
  {
    "code": "gender_not_set",
    "field": "gender",
    "detail": "Russian first-person verb forms change with the speaker's gender. Without a gender the agent guesses which forms to use; set gender to male or female (PATCH the clone to add it later)."
  }
]
```

| Status | Code or response | When |
|--------|------------------|------|
| `400` | `{"language":"Unsupported custom voice language."}` | Language is missing or unsupported |
| `400` | `{"audio":"An audio file is required."}` | Audio part is missing |
| `400` | `{"audio":"Audio must be a WAV or MP3 file."}` | Filename extension and MIME type are not an accepted WAV or MP3 combination |
| `400` | `{"audio":"Audio must be 4 MB or smaller."}` | File exceeds 4 MB |
| `400` | `{"audio":"Audio must not be empty."}` | File is empty |
| `400` | `{"consent":"Consent must be exactly true to confirm rights to clone this voice."}` | Consent is not exactly `true` |
| `400` | `{"name":"Name is required."}` | Name is empty or absent |
| `400` | `{"name":"Name must be 100 characters or fewer."}` | Name is too long |
| `400` | `{"gender":"Gender must be male or female."}` | Gender is non-empty but not `male` or `female`; an empty value counts as omitted |
| `403` | `cloning_not_enabled` | Custom voice cloning is not enabled for the organization |
| `422` | `custom_voice_quota_exceeded` | The organization's ThunderPhone custom voice limit is already in use |

The `403` response is:

```json
{
  "code": "cloning_not_enabled",
  "detail": "Custom voice cloning is not enabled. Submit a quota request to request access."
}
```

The quota response includes current ThunderPhone usage and limit:

```json
{
  "code": "custom_voice_quota_exceeded",
  "detail": "Custom voice quota exceeded.",
  "current": 5,
  "max": 5
}
```

## List and retrieve clones

List all non-deleted custom voices for the organization, newest first:

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/voices/clones \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response (200 OK)
[
  {
    "name": "custom:cv_2f6f90b0e9a34ee8b39be7d1",
    "public_id": "cv_2f6f90b0e9a34ee8b39be7d1",
    "display_name": "Support voice",
    "language": "en",
    "gender": "female",
    "status": "ready",
    "failure_reason": "",
    "created_at": "2026-07-30T14:12:08.317Z",
    "sample_url": "https://api.thunderphone.com/v1/voices/clones/cv_2f6f90b0e9a34ee8b39be7d1/preview?sig=..."
  }
]
```

Retrieve one clone with `GET /v1/voices/clones/{public_id}`. It returns the
same [custom voice object](#custom-voice-object), or `404 Not Found` if the
voice does not exist, belongs to another organization, or was deleted.

## Rename a clone or set its gender

`display_name` and `gender` can be changed; send either or both. The
agent-facing `name`, `custom:<public_id>`, stays stable. Use `gender` to
add one to a clone created without it.

<CodeGroup>
```bash cURL
curl -X PATCH \
  https://api.thunderphone.com/v1/voices/clones/cv_2f6f90b0e9a34ee8b39be7d1 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"Billing voice","gender":"female"}'
```
</CodeGroup>

Returns the updated [custom voice object](#custom-voice-object).

| Status | Response | When |
|--------|----------|------|
| `400` | `{"detail":"Only display_name and gender may be changed."}` | The body is empty or contains any other field |
| `400` | `{"display_name":"Display name must be between 1 and 100 characters."}` | `display_name` is present but empty or too long |
| `400` | `{"gender":"Gender must be male or female."}` | `gender` is present but unsupported |
| `404` | Not found | The clone is unavailable to the organization |

## Delete a clone

<CodeGroup>
```bash cURL
curl -X DELETE \
  https://api.thunderphone.com/v1/voices/clones/cv_2f6f90b0e9a34ee8b39be7d1 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns `204 No Content`. Deletion is soft: the voice disappears from clone
and catalog responses immediately and frees one ThunderPhone custom voice
slot. A deleted voice can no longer be assigned to an agent.

## Play the preview

A ready clone's `sample_url` is a signed URL to:

`GET /v1/voices/clones/{public_id}/preview?sig={signature}`

It streams `audio/mpeg` without an Authorization header. You can also call
the endpoint without `sig` while authenticated:

<CodeGroup>
```bash cURL
curl \
  https://api.thunderphone.com/v1/voices/clones/cv_2f6f90b0e9a34ee8b39be7d1/preview \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  --output preview.mp3
```
</CodeGroup>

An unsigned, unauthenticated request—or one with an invalid or expired
signature—returns `401 Unauthorized`. A valid request returns `404 Not Found`
when the clone is not ready or has no stored preview.

## Use a custom voice

Once `status` is `ready`, pass the clone's `name` to an agent's `voice` or
`draft_voice` field:

<CodeGroup>
```bash cURL
curl -X PATCH https://api.thunderphone.com/v1/agents/42 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"voice":"custom:cv_2f6f90b0e9a34ee8b39be7d1"}'
```
</CodeGroup>

The clone must be ready and owned by the agent's organization. A clone from
another organization, a non-ready clone, or a deleted clone is rejected.

Ready custom voices also appear at the start of the `voices` array returned
by [`GET /v1/voices`](/api-reference/agents#voices):

```json
{
  "name": "custom:cv_2f6f90b0e9a34ee8b39be7d1",
  "display_name": "Billing voice",
  "description": "",
  "gender": "female",
  "category": "custom",
  "accent": null,
  "languages": ["en"],
  "custom": true,
  "surcharges": {"en": 1},
  "sample_url": "https://api.thunderphone.com/v1/voices/clones/cv_2f6f90b0e9a34ee8b39be7d1/preview?sig=..."
}
```

The `surcharges` object is empty when the organization has no custom voice
surcharge. You can filter the catalog with `?category=custom`, `?languages=en`,
or `?gender=female`.

## Bring your own Cartesia voice

Organizations that keep their voices in their own [Cartesia](https://cartesia.ai)
account can have ThunderPhone speak with them directly. Speech is synthesized
on your Cartesia account with Cartesia's Sonic model, and Cartesia bills your
account for it. Standard ThunderPhone realtime rates still apply, but there is
no premium-voice surcharge. ThunderPhone does not copy the Cartesia voice or
its clone audio into the ThunderPhone voice library: staff store your Cartesia
API key encrypted, each call record keeps the selected voice id, and the key
is never returned.

This is set up by ThunderPhone for your organization, not through the API or
dashboard: contact support with a Cartesia API key created for ThunderPhone's
use. We hold it encrypted and never return it. Until it is configured, a
Cartesia voice is rejected like any unknown voice
(`"Unsupported voice option."`).

Once configured, name the voice on a [realtime session](/api-reference/realtime)
with an inline configuration, using the voice id from your Cartesia dashboard.
For a WebSocket `session.update`, set `session.audio.output.voice`; for a
managed session created with `POST /v1/realtime/sessions`, set `config.voice`:

```json
{
  "type": "session.update",
  "session": {
    "instructions": "You are Javier's assistant, calling to confirm Sunday's plans.",
    "audio": {"output": {"voice": "cartesia:a0e99841-438c-4a64-b679-ae501e7d6091"}},
    "config": {"product": "bolt"}
  }
}
```

- The value is `cartesia:` followed by the voice's UUID. It is accepted only
  on inline realtime sessions; saved agents, ThunderPhone-managed telephony
  calls and widget sessions keep using ThunderPhone and custom voices.
- Any voice in your Cartesia account works, including instant clones, so you
  can choose a different voice per session. The id is not verified against
  your account at session setup: a well-formed id that Cartesia rejects falls
  back on the first turn (below).
- On ordinary `bolt` conversational turns, ThunderPhone streams the reply text
  to Cartesia as it is generated, for the lowest latency. Some paths, such as
  IVR menu turns, and the other products collect the full reply before
  synthesis.
- If synthesis fails before any audio has started (a revoked key, no remaining
  credits, or a deleted voice), ThunderPhone speaks that turn in the default
  ThunderPhone voice rather than leaving it silent and records the error in
  its server logs with the call id. A failure after audio has started is not
  retried, so speech is never repeated.

