Reference
Soutiva API v1
One JSON API over HTTPS for teams that would rather generate audio from their own service than from a browser. Everything the Studio can do with a script — voices, models, languages, quota — is available here, scoped to a token you can revoke.
Base URL: https://soutiva.com/api/v1. All requests are JSON; all responses are JSON except
POST /speech when you ask for Accept: audio/mpeg.
Engines, and how this platform talks to them
Synthesis is performed by the speech engine this installation runs on. An engine can throttle, refuse a payload or fail transiently, and when that happens the API says so plainly and asks you to try again —502 engine_rate_limited, 502 engine_error — rather than
pretending the request succeeded. A failed request is never charged.
Authentication
Send a token as a Bearer credential on every request. Tokens are minted in the workspace in the dashboard under Settings → API tokens, are shown exactly once, and are stored only as a SHA-256 hash. Revoking a token takes effect on the next request.
Header
Authorization: Bearer $SOUTIVA_API_TOKEN
X-Api-Key is accepted as a fallback for clients that cannot set
Authorization cleanly. A missing or revoked token answers 401; a
valid token whose plan lacks API access, or which does not carry the ability a route needs,
answers 403.
Abilities
| Ability | Unlocks |
|---|---|
| tts:generate | POST /v1/speech — the only ability that spends quota |
| tts:read | GET /v1/generations/{uuid} |
| voices:read | GET /v1/voices, GET /v1/models |
| usage:read | GET /v1/usage, GET /v1/me |
Quick start
The cheapest way to check a token is GET /v1/me: it spends no quota and returns
the account and the abilities the token carries.
Laravel (Http client)
use Illuminate\Support\Facades\Http;
$account = Http::withToken('$SOUTIVA_API_TOKEN')
->acceptJson()
->baseUrl('https://soutiva.com/api/v1')
->get('/me')
->throw()
->json();
$account['plan']; // "pro"
$account['abilities']; // ["tts:generate", "tts:read", "voices:read", "usage:read"]
curl
curl --request GET \
--url https://soutiva.com/api/v1/me \
--header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
--header 'Accept: application/json'
Node (fetch, 18+)
const response = await fetch('https://soutiva.com/api/v1/me', {
headers: {
Authorization: 'Bearer $SOUTIVA_API_TOKEN',
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`API answered ${response.status}`);
}
const account = await response.json();
console.log(account.plan, account.abilities);
Testing from this page
Every endpoint below has a Try it panel. Paste one of your tokens once and each panel will send a real request from this browser, so you can confirm the token, the payload and the response before writing any client code. A failed call shows its actual error body rather than a description of one.
Held in this browser tab only (sessionStorage) and cleared when the tab closes. Requests go straight from your browser to the API host — the token is never sent to this page's server and is not written to any log here.
These panels spend quota
POST /v1/speech and POST /v1/generations/{uuid}/retry bill the
characters they synthesise, exactly as a real client would. Prefer
GET /v1/me or GET /v1/usage to check a token, and keep the test
script short.
Rate limits
Limits are per token, not per installation, and they exist so one runaway worker cannot spend
an entire billing period in a minute. A throttled request answers 429 with
Retry-After and the body below.
| Scope | Limit | Key |
|---|---|---|
POST /v1/speech |
plan ceiling, default 20/min | API token |
Every /api/* route |
60/min | Account, then IP |
GET /v1/health |
30/min | IP |
The generation ceiling is the lower of the account's plan allowance and the installation default. Per-minute figures, by plan:
| Plan | Requests/min | Characters/month | Audio retention |
|---|---|---|---|
| Starter | 20 | 60,000 | 30 days |
| Plus | 20 | 250,000 | 60 days |
| Creator | 20 | 800,000 | 90 days |
| Growth | 20 | 2,000,000 | 180 days |
| Business | 20 | 5,000,000 | 365 days |
429 response
{
"message": "API generation rate limit reached for this token.",
"error": "rate_limited"
}
Errors
Failures always carry a message that is safe to show a user and an
error machine code. Quota failures add a context block with the
numbers involved and a refreshed usage block. No upstream payload, header or URL
is ever echoed.
| Status | Error | Meaning |
|---|---|---|
| 401 | unauthenticated |
No token, or the token is revoked, expired or belongs to a suspended account |
| 403 | insufficient_token_ability |
The token is valid but does not carry the ability this route needs |
| 403 | api_access_disabled |
The account's plan does not include API access |
| 404 | not_found |
No such generation for this account — another account's id answers 404 as well |
| 409 | already_completed |
A retry was asked for, but that generation already has audio. Its audio_url still works until it expires |
| 409 | generation_in_progress |
A retry was asked for while the generation is still queued or processing |
| 409 | request_id_conflict |
That request_id belongs to another account; an idempotency key is never replayable across accounts |
| 402 | characters_exhausted |
Period allowance used up: upgrade or wait for the next period |
| 402 | generations_exhausted |
Period generation count used up |
| 402 | insufficient_characters_remaining |
The script is longer than what remains this period |
| 402 | trial_expired, subscription_inactive, no_plan, account_suspended, concurrent_limit |
The account cannot generate right now; message says which |
| 422 | text_too_long, text_empty, model_not_allowed, language_not_allowed, voice_not_allowed |
The request itself is outside this plan's capability — fix the payload |
| 422 | validation shape: {message, errors} |
A field is missing or malformed; errors maps field → messages |
| 429 | rate_limited |
Too many requests; respect Retry-After and back off |
| 502 | engine_unauthorized, engine_forbidden, engine_rate_limited, engine_invalid_request, engine_error, engine_unreachable, empty_audio |
The engine refused or failed the synthesis; the message is safe to show a user |
| 503 | engine_not_configured |
This installation has no engine connected; the operator must finish the setup |
Quota failure body
{
"message": "This script is 5,120 characters but only 2,040 remain in your billing period.",
"error": "insufficient_characters_remaining",
"context": {
"remaining": 2040,
"requested": 5120
},
"usage": {
"plan": "pro",
"plan_name": "Pro",
"is_trialing": false,
"trial_days_remaining": null,
"period_ends_at": "2026-06-01T00:00:00+00:00",
"characters": {
"limit": 500000,
"used": 120480,
"remaining": 379520,
"percent": 24.1,
"max_per_request": 5000
},
"generations": {
"limit": 2000,
"used": 431,
"remaining": 1569,
"percent": 21.6
},
"retention_days": 90,
"api_access": true,
"allowed_languages": [
"ar",
"en"
],
"allowed_models": [
"MetaPhiliaTTS-v1"
],
"can_generate": true
}
}
Engine failure body
When the failure happened after the generation was recorded, the body also carries the
generation's id, whether a retry is worth attempting, and the URL that
attempts it. A client does not have to re-upload a script to try again.
{
"message": "The speech engine is busy. Try again shortly.",
"error": "engine_rate_limited",
"id": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"retryable": true,
"retry_url": "https://soutiva.com/api/v1/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11/retry"
}
Long scripts and chunking
Send the whole script. There is nothing to split on the client, and no length that makes
POST /v1/speech behave differently from the browser interface — both call the same
service and are held to the same ceiling.
Internally a script longer than 500 characters is divided at
natural sentence boundaries, each piece is synthesised, and the audio is stitched back into a
single MP3. The caller sees one generation, one characters count and one
audio_url; the splitting is not visible in the response and does not need to be.
The per-request ceiling
The ceiling is the tightest of three numbers: the installation's absolute cap
(5,000 characters here), the plan's per-request allowance, and
the engine's own maximum. Read the effective value from
GET /v1/usage → characters.max_per_request rather than assuming one,
and check it before submitting a batch.
A script over the ceiling answers 422 text_too_long with the real number in
context.max, so a client can trim intelligently or tell the user what the limit
actually is.
Characters are counted in code points
Counting uses UTF-8 code points (mb_strlen), which is what speech engines bill
on. A 1,000-character Arabic script counts as 1,000 characters, not the 2,000 bytes it occupies
— so RTL scripts are not charged double.
What a long script costs in time
A 1,500-character script is three synthesis calls, and the request
lasts as long as the slowest one. If your queue is not sync, a long script answers
202 with a status_url straight away and you poll it; on a
sync queue the request itself blocks until the audio exists. For anything longer
than a few hundred characters, submitting one request at a time and polling is kinder to your
own timeouts than a large parallel batch.
Retries and recovery
Upstream speech endpoints throttle and occasionally drop a connection. Two mechanisms cover that, and a robust client uses both.
Automatic, per chunk
A synthesis call that fails in a way that could succeed on a second attempt — throttling
(429), a server error (5xx) or a transport failure — is retried
automatically before the request is given up on. Retrying happens per chunk, so a failure
three-quarters of the way through a long script re-runs only that piece rather than everything
before it.
Failures that a second attempt cannot fix are never retried: rejected credentials, a payload the engine refuses, and empty audio. They are reported immediately and cost the caller nothing.
The number of extra attempts and the wait between them are operator settings
(TTS_CHUNK_RETRY_ATTEMPTS, TTS_CHUNK_RETRY_DELAY_MS), so a deployment
can be tuned without a client change. A client should still keep its own backoff, because
automatic retries are bounded.
Explicit, by the id you already hold
When a generation still fails, the 502 body carries the generation's
id and, when a retry is worth attempting, a retry_url. Posting that
URL re-runs the same generation — same id, same request_id — which
means a polling loop, a database row or a log entry keyed on the id keeps pointing at the right
thing. See Retry a generation.
Entitlement is re-checked on every retry. The characters are charged to the period that is current when the audio is produced, not when the original request was made.
Idempotency: repeating a request is safe
If you send request_id, it is an idempotency key and repeating the request is
defined behaviour rather than an accident:
- The generation is completed — the original response is returned and no new work is done or charged.
- The generation failed — the same key re-runs it, reusing the same row, so a client that retries after a 502 gets a fresh attempt instead of an error.
- The generation is still running — the same
202andstatus_urlas the first call.
A request_id already used by a different account answers
409 request_id_conflict; it is never replayable across accounts.
When the queue is not sync
On an installation whose queue driver is not sync, a queued job also retries itself
a few times with a widening backoff, independently of the per-chunk retries above. A
generation that has already produced audio is never re-run by a duplicate job.
/v1/speech
Create a generation
Synthesise one script and return the audio, or a job to poll. Any length up to the per-request ceiling is accepted: longer scripts are split at sentence boundaries and stitched back into one file, exactly as the browser interface does it. Text is counted in UTF-8 code points (mb_strlen), which is what every engine bills on.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
| text | string | Required | The script. Required, non-empty, and no longer than the effective per-request ceiling — the tightest of the installation cap (5,000 characters here), the plan allowance and the engine maximum. Read it from GET /v1/usage → characters.max_per_request. Longer scripts do not need splitting by the client. |
| voice | string | Required | A voice id from GET /v1/voices. A numeric database id is accepted too, but the id from the catalogue is the stable choice. The voice must belong to the language you send. |
| model | string | Required | An engine id from GET /v1/models, e.g. "MetaPhiliaTTS-v1". Matched case-insensitively. Omit it only if you have exactly one engine and are happy to depend on that. |
| language | string | Required | An ISO 639-1 code from the Languages table below. The voice must belong to this language. |
| request_id | uuid | Optional | Your own idempotency key. Send one and a repeated request is safe: it returns the original generation, or re-runs it if it failed. Echoed back as request_id; generated when omitted. |
Request
curl
curl --request POST \
--url https://soutiva.com/api/v1/speech \
--header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"text": "First light on the ridge. The crew is moving at six.",
"voice": "rPNcQ53R703tTmtue1AT",
"model": "MetaPhiliaTTS-v1",
"language": "en"
}'
JavaScript (fetch)
// JSON response: metadata plus time-limited signed URLs.
const response = await fetch('https://soutiva.com/api/v1/speech', {
method: 'POST',
headers: {
Authorization: 'Bearer $SOUTIVA_API_TOKEN',
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
text: 'First light on the ridge. The crew is moving at six.',
voice: 'rPNcQ53R703tTmtue1AT',
model: 'MetaPhiliaTTS-v1',
language: 'en',
}),
});
const generation = await response.json();
if (response.status === 202) {
console.log('queued, poll', generation.status_url);
} else {
console.log(generation.characters, generation.audio_url);
}
// Accept: audio/mpeg returns the MP3 bytes themselves — no JSON envelope.
const audio = await fetch('https://soutiva.com/api/v1/speech', {
method: 'POST',
headers: {
Authorization: 'Bearer $SOUTIVA_API_TOKEN',
'Content-Type': 'application/json',
Accept: 'audio/mpeg',
},
body: JSON.stringify({
text: 'Short line.',
voice: 'rPNcQ53R703tTmtue1AT',
model: 'MetaPhiliaTTS-v1',
language: 'en',
}),
});
const bytes = await audio.arrayBuffer();
PHP (Laravel Http)
use Illuminate\Support\Facades\Http;
$response = Http::withToken('$SOUTIVA_API_TOKEN')
->acceptJson()
->post('https://soutiva.com/api/v1/speech', [
'text' => 'First light on the ridge. The crew is moving at six.',
'voice' => 'rPNcQ53R703tTmtue1AT',
'model' => 'MetaPhiliaTTS-v1',
'language' => 'en',
]);
if ($response->status() === 402) {
// Quota, not a bug: report the reason and the refreshed usage block.
$failure = $response->json();
logger()->warning('TTS quota', $failure['usage']);
}
$generation = $response->throw()->json();
// Stream the bytes instead of storing them:
$mp3 = Http::withToken('$SOUTIVA_API_TOKEN')
->withHeaders(['Accept' => 'audio/mpeg'])
->post('https://soutiva.com/api/v1/speech', [
'text' => 'Short line.',
'voice' => 'rPNcQ53R703tTmtue1AT',
'model' => 'MetaPhiliaTTS-v1',
'language' => 'en',
])
->throw()
->body();
Try it
Runs in your browser with the token you pasted above.
This synthesises audio and spends real characters from your allowance, exactly as a client would. Keep the script short while you are testing.
Response
201 — completed, Accept: application/json
{
"id": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"request_id": "5f2a2b7c-6c1e-4d3f-9a1b-2c3d4e5f6a7b",
"status": "completed",
"characters": 53,
"language": "en",
"voice": "rPNcQ53R703tTmtue1AT",
"model": "MetaPhiliaTTS-v1",
"duration_ms": 3240,
"audio_url": "https://soutiva.com/audio/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11?expires=1770000000&signature=8c1f4a2e9b0d",
"download_url": "https://soutiva.com/audio/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11/download?expires=1770000000&signature=2f7c1d4b8a90",
"expires_at": "2026-08-19T10:04:11+00:00",
"usage": {
"plan": "pro",
"plan_name": "Pro",
"is_trialing": false,
"trial_days_remaining": null,
"period_ends_at": "2026-06-01T00:00:00+00:00",
"characters": {
"limit": 500000,
"used": 120480,
"remaining": 379520,
"percent": 24.1,
"max_per_request": 5000
},
"generations": {
"limit": 2000,
"used": 431,
"remaining": 1569,
"percent": 21.6
},
"retention_days": 90,
"api_access": true,
"allowed_languages": [
"ar",
"en"
],
"allowed_models": [
"MetaPhiliaTTS-v1"
],
"can_generate": true
}
}
202 — the queue is not sync; poll status_url
{
"id": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"request_id": "5f2a2b7c-6c1e-4d3f-9a1b-2c3d4e5f6a7b",
"status": "queued",
"status_url": "https://soutiva.com/api/v1/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"characters": 53,
"language": "en",
"voice": "rPNcQ53R703tTmtue1AT",
"model": "MetaPhiliaTTS-v1",
"duration_ms": null,
"audio_url": null,
"download_url": null,
"expires_at": "2026-08-19T10:04:11+00:00",
"usage": {
"plan": "pro",
"plan_name": "Pro",
"is_trialing": false,
"trial_days_remaining": null,
"period_ends_at": "2026-06-01T00:00:00+00:00",
"characters": {
"limit": 500000,
"used": 120480,
"remaining": 379520,
"percent": 24.1,
"max_per_request": 5000
},
"generations": {
"limit": 2000,
"used": 431,
"remaining": 1569,
"percent": 21.6
},
"retention_days": 90,
"api_access": true,
"allowed_languages": [
"ar",
"en"
],
"allowed_models": [
"MetaPhiliaTTS-v1"
],
"can_generate": true
}
}
201 — Accept: audio/mpeg, the MP3 itself
HTTP/1.1 201 Created
Content-Type: audio/mpeg
Content-Disposition: inline; filename="first-light-on-the-ridge-b0f2f9c4.mp3"
Content-Length: 48213
X-Generation-Id: b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11
<48,213 bytes of MP3>
Errors
-
401
unauthenticated— no token, or the token was revoked -
402
characters_exhausted— the period allowance is used up -
402
insufficient_characters_remaining— the script exceeds what is left -
403
insufficient_token_ability— the token lacks tts:generate -
403
api_access_disabled— the plan does not include API access -
422
text_too_long— longer than the plan's per-request ceiling -
422
model_not_allowed, language_not_allowed, voice_not_allowed— the plan does not include that capability -
429
rate_limited— the token exceeded its per-minute generation limit -
502
engine_*— the engine refused or failed the synthesis -
503
engine_not_configured— the installation has no engine connected
/v1/generations/{uuid}
Generation status
The current state of one generation on this account. Poll this after a 202; the row keeps its status once it is completed or failed, so a retry of the same call is harmless.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
| uuid | string (path) | Required | The id returned by POST /speech. A uuid that belongs to another account answers 404, exactly like one that does not exist. |
Request
curl
curl --request GET \
--url {BASE_URL}/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11 \
--header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
--header 'Accept: application/json'
JavaScript (fetch)
const response = await fetch('{BASE_URL}/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11', {
headers: {
Authorization: 'Bearer $SOUTIVA_API_TOKEN',
Accept: 'application/json',
},
});
const generation = await response.json();
if (generation.status === 'completed') {
const audio = await fetch(generation.audio_url); // signed, expires
const bytes = await audio.arrayBuffer();
}
PHP (Laravel Http)
use Illuminate\Support\Facades\Http;
$generation = Http::withToken('$SOUTIVA_API_TOKEN')
->acceptJson()
->baseUrl('{BASE_URL}')
->get('/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11')
->throw()
->json();
$generation['status']; // queued | processing | completed | failed
$generation['audio_url']; // signed stream URL while the audio exists
$generation['error']; // ['code' => 'engine_rate_limited', 'message' => '…'] when failed
Try it
Runs in your browser with the token you pasted above.
Use the id returned by POST /v1/speech. An id belonging to another account answers 404, by design.
Response
200 — completed
{
"id": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"request_id": "5f2a2b7c-6c1e-4d3f-9a1b-2c3d4e5f6a7b",
"status": "completed",
"stage": "ready",
"characters": 53,
"language": "en",
"voice": "rPNcQ53R703tTmtue1AT",
"voice_name": "Jordan",
"model": "MetaPhiliaTTS-v1",
"model_name": "MetaPhiliaTTS-v1",
"duration_ms": 3240,
"audio_bytes": 48213,
"mime_type": "audio/mpeg",
"audio_url": "https://soutiva.com/audio/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11?expires=1770000000&signature=8c1f4a2e9b0d",
"download_url": "https://soutiva.com/audio/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11/download?expires=1770000000&signature=2f7c1d4b8a90",
"expires_at": "2026-08-19T10:04:11+00:00",
"created_at": "2026-05-21T10:04:11+00:00",
"completed_at": "2026-05-21T10:04:14+00:00",
"error": null
}
200 — failed
{
"id": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"request_id": "5f2a2b7c-6c1e-4d3f-9a1b-2c3d4e5f6a7b",
"status": "failed",
"stage": "failed",
"characters": 53,
"language": "en",
"voice": "rPNcQ53R703tTmtue1AT",
"voice_name": "Jordan",
"model": "MetaPhiliaTTS-v1",
"model_name": "MetaPhiliaTTS-v1",
"duration_ms": null,
"audio_bytes": null,
"mime_type": null,
"audio_url": null,
"download_url": null,
"expires_at": null,
"created_at": "2026-05-21T10:04:11+00:00",
"completed_at": null,
"error": {
"code": "engine_rate_limited",
"message": "The speech engine is busy. Try again shortly."
}
}
Errors
-
401
unauthenticated— no usable token -
403
insufficient_token_ability— the token lacks tts:read -
404
not_found— the id is unknown, or belongs to another account
/v1/generations/{uuid}/retry
Retry a generation
Re-run a generation that failed, without re-uploading the script. The existing row is reused rather than copied, so its id, its request_id and anything you keyed on them stay valid. Requires the tts:generate ability, because it spends quota, and is rate limited like POST /speech.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
| uuid | string (path) | Required | The id of the failed generation. A generation that already has audio answers 409 already_completed, and one belonging to another account answers 404. |
Request
curl
curl --request POST \
--url https://soutiva.com/api/v1/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11/retry \
--header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
--header 'Accept: application/json'
JavaScript (fetch)
// You would normally take this id from a preceding 502, whose body
// carries both the id and a retry_url you can post to directly.
const response = await fetch(
'https://soutiva.com/api/v1/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11/retry',
{
method: 'POST',
headers: {
Authorization: 'Bearer $SOUTIVA_API_TOKEN',
Accept: 'application/json',
},
},
);
const generation = await response.json();
console.log(generation.status, generation.audio_url);
PHP (Laravel Http)
use Illuminate\Support\Facades\Http;
$generation = Http::withToken('$SOUTIVA_API_TOKEN')
->acceptJson()
->baseUrl('https://soutiva.com/api/v1')
->post('/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11/retry')
->throw()
->json();
$generation['id']; // unchanged
$generation['retried_from']; // the same id, echoed back
Try it
Runs in your browser with the token you pasted above.
Only a failed generation can be retried. Paste the id of one from GET /v1/usage → recent_generations, or from your own records. This spends characters if it succeeds.
Response
201 — retried and finished
{
"id": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"request_id": "5f2a2b7c-6c1e-4d3f-9a1b-2c3d4e5f6a7b",
"status": "completed",
"characters": 53,
"language": "en",
"voice": "rPNcQ53R703tTmtue1AT",
"model": "MetaPhiliaTTS-v1",
"duration_ms": 3240,
"audio_url": "https://soutiva.com/audio/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11?expires=1770000000&signature=8c1f4a2e9b0d",
"download_url": "https://soutiva.com/audio/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11/download?expires=1770000000&signature=2f7c1d4b8a90",
"expires_at": "2026-08-19T10:04:11+00:00",
"usage": {
"plan": "pro",
"plan_name": "Pro",
"is_trialing": false,
"trial_days_remaining": null,
"period_ends_at": "2026-06-01T00:00:00+00:00",
"characters": {
"limit": 500000,
"used": 120480,
"remaining": 379520,
"percent": 24.1,
"max_per_request": 5000
},
"generations": {
"limit": 2000,
"used": 431,
"remaining": 1569,
"percent": 21.6
},
"retention_days": 90,
"api_access": true,
"allowed_languages": [
"ar",
"en"
],
"allowed_models": [
"MetaPhiliaTTS-v1"
],
"can_generate": true
},
"retried_from": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11"
}
202 — retried onto the queue
{
"id": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"request_id": "5f2a2b7c-6c1e-4d3f-9a1b-2c3d4e5f6a7b",
"status": "queued",
"status_url": "https://soutiva.com/api/v1/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"retry_url": "https://soutiva.com/api/v1/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11/retry",
"characters": 53,
"language": "en",
"voice": "rPNcQ53R703tTmtue1AT",
"model": "MetaPhiliaTTS-v1",
"duration_ms": null,
"audio_url": null,
"download_url": null,
"expires_at": "2026-08-19T10:04:11+00:00",
"usage": {
"plan": "pro",
"plan_name": "Pro",
"is_trialing": false,
"trial_days_remaining": null,
"period_ends_at": "2026-06-01T00:00:00+00:00",
"characters": {
"limit": 500000,
"used": 120480,
"remaining": 379520,
"percent": 24.1,
"max_per_request": 5000
},
"generations": {
"limit": 2000,
"used": 431,
"remaining": 1569,
"percent": 21.6
},
"retention_days": 90,
"api_access": true,
"allowed_languages": [
"ar",
"en"
],
"allowed_models": [
"MetaPhiliaTTS-v1"
],
"can_generate": true
},
"retried_from": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11"
}
409 — nothing to retry
{
"message": "That generation already has audio, so there is nothing to retry. Its audio_url still works until it expires.",
"error": "already_completed",
"id": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11"
}
Errors
-
401
unauthenticated— no usable token -
402
characters_exhausted, insufficient_characters_remaining— the allowance no longer covers the characters -
403
insufficient_token_ability— the token lacks tts:generate -
403
api_access_disabled— the plan does not include API access -
404
not_found— the id is unknown, or belongs to another account -
409
already_completed— the generation already has audio -
409
generation_in_progress— the generation is queued or processing -
429
rate_limited— the token exceeded its per-minute generation limit -
502
engine_*— the engine failed again; the body carries a fresh retry_url
/v1/voices
List voices
The voices this account may generate with. The plan's language and voice allow-lists are applied before pagination, so every voice returned here can be used at POST /v1/speech.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
| language | string | Optional | Filter by language code, e.g. ar or en. |
| gender | string | Optional | male or female. |
| featured | boolean | Optional | true returns only voices the operator has featured. |
| search | string | Optional | Substring match on name, description, dialect, accent and voice id. |
| per_page | integer | Optional | 1–100, default 25. |
Request
curl
curl --request GET \
--url 'https://soutiva.com/api/v1/voices?language=ar&per_page=50' \
--header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
--header 'Accept: application/json'
JavaScript (fetch)
const url = new URL('https://soutiva.com/api/v1/voices');
url.searchParams.set('language', 'ar');
url.searchParams.set('per_page', '50');
const response = await fetch(url, {
headers: {
Authorization: 'Bearer $SOUTIVA_API_TOKEN',
Accept: 'application/json',
},
});
const { data, meta } = await response.json();
console.log(meta.total, data.map((voice) => voice.id));
PHP (Laravel Http)
use Illuminate\Support\Facades\Http;
$catalogue = Http::withToken('$SOUTIVA_API_TOKEN')
->acceptJson()
->baseUrl('https://soutiva.com/api/v1')
->get('/voices', [
'language' => 'ar',
'per_page' => 50,
])
->throw()
->json();
$catalogue['meta']['total'];
Try it
Runs in your browser with the token you pasted above.
Leave the language empty to see the whole catalogue this token may use.
Response
200 — one page of the catalogue
{
"data": [
{
"id": "rPNcQ53R703tTmtue1AT",
"name": "Jordan",
"language": "en",
"language_label": "English",
"locale": "en-US",
"dialect": "US",
"accent": "General American",
"gender": "female",
"age_tier": null,
"description": "Warm, unhurried narration for long-form reads.",
"use_case": "narration",
"is_featured": true,
"is_premium": false,
"is_cloned": false
}
],
"meta": {
"count": 1,
"total": 96,
"per_page": 25,
"current_page": 1,
"last_page": 4,
"filters": {
"language": "en"
}
}
}
Errors
-
401
unauthenticated— no usable token -
403
insufficient_token_ability— the token lacks voices:read
/v1/models
List models
The engines this account may use, in the operator's display order. Send the id of one of these in the model field of POST /v1/speech. Each engine carries its own per-request character ceiling, enforced before the engine is called.
Parameters
No request parameters beyond the path and the authentication header.
Request
curl
curl --request GET \
--url https://soutiva.com/api/v1/models \
--header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
--header 'Accept: application/json'
JavaScript (fetch)
const response = await fetch('https://soutiva.com/api/v1/models', {
headers: {
Authorization: 'Bearer $SOUTIVA_API_TOKEN',
Accept: 'application/json',
},
});
const { data, meta } = await response.json();
console.log(meta.count, data.map((model) => model.id));
PHP (Laravel Http)
use Illuminate\Support\Facades\Http;
$models = Http::withToken('$SOUTIVA_API_TOKEN')
->acceptJson()
->baseUrl('https://soutiva.com/api/v1')
->get('/models')
->throw()
->json();
$models['data'][0]['id'];
Try it
Runs in your browser with the token you pasted above.
Use the id of a model from this list as the model field of POST /v1/speech.
Response
200 — every engine the plan allows
{
"data": [
{
"id": "MetaPhiliaTTS-v1",
"name": "MetaPhiliaTTS-v1",
"badge": "FAST",
"description": "Low latency, built for long-form reads.",
"quality": "High",
"quality_tier": 2,
"speed": "Fastest",
"latency_tier": 1,
"min_characters": 0,
"max_characters": 5000,
"supports_ssml": false,
"supports_language_hint": false
}
],
"meta": {
"count": 1,
"total": 1
}
}
Errors
-
401
unauthenticated— no usable token -
403
insufficient_token_ability— the token lacks voices:read
/v1/usage
Usage and quota
Exactly what the workspace shows: the current period, the allowance, what is left, and the last ten generations. Check can_generate before a batch so you do not discover the ceiling halfway through.
Parameters
No request parameters beyond the path and the authentication header.
Request
curl
curl --request GET \
--url https://soutiva.com/api/v1/usage \
--header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
--header 'Accept: application/json'
JavaScript (fetch)
const response = await fetch('https://soutiva.com/api/v1/usage', {
headers: {
Authorization: 'Bearer $SOUTIVA_API_TOKEN',
Accept: 'application/json',
},
});
const quota = await response.json();
console.log(quota.characters.remaining, quota.can_generate);
PHP (Laravel Http)
use Illuminate\Support\Facades\Http;
$quota = Http::withToken('$SOUTIVA_API_TOKEN')
->acceptJson()
->baseUrl('https://soutiva.com/api/v1')
->get('/usage')
->throw()
->json();
if (! $quota['can_generate']) {
// Pause the batch; the message lives in the 402 body, not here.
}
Try it
Runs in your browser with the token you pasted above.
Costs nothing and touches no engine. This is also the quickest way to find a failed generation id to try on the retry endpoint.
Response
200 — the quota snapshot plus the period and recent history
{
"plan": "pro",
"plan_name": "Pro",
"is_trialing": false,
"trial_days_remaining": null,
"period_ends_at": "2026-06-01T00:00:00+00:00",
"characters": {
"limit": 500000,
"used": 120480,
"remaining": 379520,
"percent": 24.1,
"max_per_request": 5000
},
"generations": {
"limit": 2000,
"used": 431,
"remaining": 1569,
"percent": 21.6
},
"retention_days": 90,
"api_access": true,
"allowed_languages": [
"ar",
"en"
],
"allowed_models": [
"MetaPhiliaTTS-v1"
],
"can_generate": true,
"period": {
"start": "2026-05-01T00:00:00+00:00",
"end": "2026-06-01T00:00:00+00:00",
"plan": "pro",
"is_trial": false,
"closed_at": null
},
"recent_generations": [
{
"id": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11",
"status": "completed",
"characters": 53,
"created_at": "2026-05-21T10:04:11+00:00"
},
{
"id": "a7c31b90-4d2e-4f18-8b6c-0e5f7a9c2d31",
"status": "failed",
"characters": 812,
"created_at": "2026-05-21T09:41:02+00:00"
}
]
}
Errors
-
401
unauthenticated— no usable token -
403
insufficient_token_ability— the token lacks usage:read
/v1/me
Token and account
Who this token belongs to, which plan is attached, and what the token itself may do. Use it as a health check in your own deployment: it costs nothing and touches no engine.
Parameters
No request parameters beyond the path and the authentication header.
Request
curl
curl --request GET \
--url https://soutiva.com/api/v1/me \
--header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
--header 'Accept: application/json'
JavaScript (fetch)
const response = await fetch('https://soutiva.com/api/v1/me', {
headers: {
Authorization: 'Bearer $SOUTIVA_API_TOKEN',
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`API answered ${response.status}`);
}
const account = await response.json();
console.log(account.plan, account.abilities);
PHP (Laravel Http)
use Illuminate\Support\Facades\Http;
$account = Http::withToken('$SOUTIVA_API_TOKEN')
->acceptJson()
->baseUrl('https://soutiva.com/api/v1')
->get('/me')
->throw()
->json();
$account['plan']; // "pro"
$account['abilities']; // ["tts:generate", "tts:read", "voices:read", "usage:read"]
Try it
Runs in your browser with the token you pasted above.
The cheapest way to check a token: no quota, no synthesis, no side effects.
Response
200 — the token owner
{
"id": 41,
"name": "Leen Haddad",
"email": "[email protected]",
"plan": "pro",
"abilities": [
"tts:generate",
"tts:read",
"voices:read",
"usage:read"
],
"token": {
"name": "Staging worker",
"last_used_at": "2026-05-21T09:58:00+00:00",
"expires_at": null
}
}
Errors
-
401
unauthenticated— no usable token -
403
insufficient_token_ability— the token lacks usage:read
Languages
language is one of these codes. The chosen voice must belong to the same language,
and a plan that restricts languages will answer 422 language_not_allowed for
anything outside its list. Arabic and Urdu are right-to-left: send the text as UTF-8 and leave
directionality to the player.
| Code | Language | Native | Direction | Engine hint |
|---|---|---|---|---|
| ar | Arabic | العربية | RTL | arabic |
| en | English | English | LTR | english |
| fr | French | Français | LTR | french |
| es | Spanish | Español | LTR | spanish |
| de | German | Deutsch | LTR | german |
| it | Italian | Italiano | LTR | italian |
| pt | Portuguese | Português | LTR | portuguese |
| nl | Dutch | Nederlands | LTR | dutch |
| pl | Polish | Polski | LTR | polish |
| ru | Russian | Русский | LTR | russian |
| tr | Turkish | Türkçe | LTR | turkish |
| zh | Chinese | 中文 | LTR | mandarin |
| ja | Japanese | 日本語 | LTR | japanese |
| ko | Korean | 한국어 | LTR | korean |
| hi | Hindi | हिन्दी | LTR | hindi |
| ur | Urdu | اردو | RTL | urdu |
Voices
Grouped by language and capped at 40 rows here so the page stays readable. The live catalogue —
with filtering, search and pagination — is GET /v1/voices, and the plan's
allow-list is applied to it, so anything the API returns can be used immediately.
Arabic (12 shown)
| Voice id | Name | Dialect | Gender | Use case |
|---|---|---|---|---|
| rPNcQ53R703tTmtue1AT | Mazen Lawand | Modern Standard | Male | Narration |
| yXEnnEln9armDCyhkXcA | Jeddawi | Saudi | Male | Narration |
| XTa3iQyMA6f1qrI4F6kZ | Sara | Modern Standard | Female | Narration |
| IES4nrmZdUBHByLBde0P | Haytham | Egyptian | Male | Advertising |
| pCKbQ4EPGE06zpEPGNvS | Abdullah | Modern Standard | Male | Corporate |
| a1KZUXKFVFDOb33I1uqr | Salma | Levantine | Female | Narration |
| jAAHNNqlbAX9iWjJPEtE | Sara (Jordanian) | Jordanian | Female | Narration |
| 4wf10lgibMnboGJGCLrP | Farah | Jordanian | Female | Narration |
| drMurExmkWVIH5nW8snR | Khaled Alnajjar | Palestinian | Male | Narration |
| OFHP1Qg30FPoNfkUFFlA | Adam | Egyptian | Male | Narration |
| mRdG9GYEjJmIzqbYTidv | Sana | Modern Standard | Female | Narration |
| XdoLPWNt7ytn6BtU4FBf | Abdullah (Bass) | Modern Standard | Male | Trailer |
German (1 shown)
| Voice id | Name | Dialect | Gender | Use case |
|---|---|---|---|---|
| de-standard-female | Ellen | Standard German | Female | Corporate |
English (11 shown)
| Voice id | Name | Dialect | Gender | Use case |
|---|---|---|---|---|
| 6OzrBCQf8cjERkYgzSg8 | Young Jamal | — | Male | — |
| OYTbf65OHHFELVut7v2H | Hope (Calm) | — | Female | — |
| g6xIsTj2HwM6VR4iXFCw | Jessica A. Bogart | — | Female | — |
| SF9uvIlY93SJRMdV5jeP | Andrew Griffin | — | Male | — |
| NOpBlnGInO9m6vDvFkFC | Spuds Oxley | — | Male | — |
| EkK5I93UQWFDigLMpZcX | James | — | Male | — |
| uju3wxzG5OhpWcoi3SMy | Michael C. Vincent | — | Male | — |
| uYXf8XasLslADfZ2MB4u | Hope (Bubbly) | — | Female | — |
| o2zd9K5QOO7ppTb04Lx0 | Gabriel Ripley | — | Male | — |
| IRHApOXLvnW57QJPQH2P | Adam | — | Male | — |
| 1SM7GgM6IMuvQlz2BwM3 | Mark (Casual) | — | Male | — |
Open the full catalogue in the API — it answers the same
fields, with per_page up to 100.
Engines
An engine sets latency, quality and its own per-request character ceiling. Send its
id in the model field of POST /v1/speech. The API refuses
an engine outside your plan with 422 model_not_allowed.
Engine ids are the platform's own names, not those of any upstream vendor. The engine a request
runs on is this installation's concern: it may change between requests, and nothing in this API
identifies it beyond the name below. Treat the id as an opaque string and read it from
GET /v1/models rather than hard-coding it — the operator can rename an engine, and
a renamed engine is rejected under its old name.
| Engine id | Description | Badge | Quality | Speed | Max characters |
|---|---|---|---|---|---|
| MetaPhiliaTTS-v1 | MetaPhilia TTS engine — fast, built for long-form reads. | Fast | High | Fastest | 2,000 |
Questions about a specific status code? Include the request_id from the response —
it identifies the generation in the operator's logs without exposing your script.