Open in
Custom voices
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.
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
{
"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.
curl https://api.thunderphone.com/v1/voices/policy \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{
"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.
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"
}'| 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 |
{
"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
curl https://api.thunderphone.com/v1/voices/quota-requests/current \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{
"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.
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"| 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 |
{
"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 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):
"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:
{
"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:
{
"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:
curl https://api.thunderphone.com/v1/voices/clones \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"[
{
"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, 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.
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"}'Returns the updated 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
curl -X DELETE \
https://api.thunderphone.com/v1/voices/clones/cv_2f6f90b0e9a34ee8b39be7d1 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"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:
curl \
https://api.thunderphone.com/v1/voices/clones/cv_2f6f90b0e9a34ee8b39be7d1/preview \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
--output preview.mp3An 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:
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"}'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:
{
"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 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
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:
{
"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
boltconversational 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.