تجاوز إلى المحتوى
Soutiva

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

http
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

Token abilities and the routes they unlock
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)

php
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

bash
curl --request GET \
  --url https://soutiva.com/api/v1/me \
  --header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
  --header 'Accept: application/json'

Node (fetch, 18+)

javascript
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.

Rate limits applied to API requests
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:

API allowances 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

json
{
    "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 codes, error codes and what they mean
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

json
{
    "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.

json
{
    "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 202 and status_url as 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.

POST /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

POST /v1/speech 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

bash
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)

javascript
// 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)

php
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

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

json
{
    "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

json
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
GET /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

GET /v1/generations/{uuid} 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

bash
curl --request GET \
  --url {BASE_URL}/generations/b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11 \
  --header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
  --header 'Accept: application/json'

JavaScript (fetch)

javascript
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)

php
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

json
{
    "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

json
{
    "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
POST /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

POST /v1/generations/{uuid}/retry 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

bash
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)

javascript
// 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)

php
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

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
    },
    "retried_from": "b0f2f9c4-1c2e-4a0d-9f3b-2f0c9a7d5e11"
}

202 — retried onto the queue

json
{
    "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

json
{
    "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
GET /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

GET /v1/voices 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

bash
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)

javascript
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)

php
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

json
{
    "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
GET /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

bash
curl --request GET \
  --url https://soutiva.com/api/v1/models \
  --header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
  --header 'Accept: application/json'

JavaScript (fetch)

javascript
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)

php
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

json
{
    "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
GET /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

bash
curl --request GET \
  --url https://soutiva.com/api/v1/usage \
  --header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
  --header 'Accept: application/json'

JavaScript (fetch)

javascript
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)

php
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

json
{
    "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
GET /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

bash
curl --request GET \
  --url https://soutiva.com/api/v1/me \
  --header 'Authorization: Bearer $SOUTIVA_API_TOKEN' \
  --header 'Accept: application/json'

JavaScript (fetch)

javascript
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)

php
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

json
{
    "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.

Language codes accepted by POST /v1/speech
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)

Arabic voices
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)

German voices
Voice id Name Dialect Gender Use case
de-standard-female Ellen Standard German Female Corporate

English (11 shown)

English voices
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.

Engines available on this installation
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.