API Reference

Complete specification for the Phonetico API. The same API as an OpenAPI 3 document, generated from the routes themselves: /v1/openapi.json.

Endpoints

MethodPathBodyUse for
POST/v1/asrJSON (base64 audio)Programmatic clients with audio already in memory.
POST/v1/asr/uploadmultipart/form-dataDirect file uploads. Easier from cURL, browsers, simple scripts.
GET/v1/asr/limits-Live operator-tunable limits (size, duration, rate). Public, no auth.
POST, GET/v1/asr/jobsJSON; the recording in parts or by URLRecordings of any length, and video: a job in the background, with the result as JSON, TXT, SRT, VTT or DOCX.
POST/v1/ttsJSON (text + voice spec)Text-to-speech synthesis, in a preset voice or from your own reference clip.
POST/v1/tts/jobsJSON (the /v1/tts body)Text of any length up to the limit: synthesized in the background, the audio kept to fetch.
GET/v1/tts/voices-Lists every active preset voice in the catalog.
GET/v1/tts/saved-voices-Lists the voices your account saved in the portal.
GET/v1/tts/voices/previews-The voices you can hear before choosing, with the sentence each reads. Public, no auth.
GET/v1/tts/voices/:id/preview-A preset voice reading a fixed sentence, as WAV. Public, no auth.
GET/v1/tts/limits-Live operator-tunable TTS limits (max text length, per-tier char quotas). Public, no auth.

POST /v1/asr

Transcribe audio sent as base64-encoded JSON. Use this when your client already has the audio in memory.

HTTP
POST https://api.phonetico.ai/v1/asr
Authorization: Bearer pk_live_your_key
Content-Type: application/json

Request body

FieldTypeRequiredDescription
audiostringYesBase64-encoded audio. Supports WAV, WebM, OGG, MP3, FLAC, and raw PCM Int16.
localeCodestringYesLanguage code (ISO 639-3): amh, orm, tir, sid, wal, sgw. See Languages for full names and beta status. Or auto: the language is identified from the audio among the languages offered for transcription (with model named). The response carries the transcript, and its localeCode stays auto. Audio whose language cannot be settled returns 422 with reason: language_undetermined; name the language and resend. Not available for jobs or streams.
modelstringWith autoThe model to transcribe with, such as phonetico-transcribe-1, or phonetico-transcribe for the latest (GET /v1/models?service=asr). Required with localeCode: auto. Omitting it is deprecated: the language's default model answers, and the response names it in model.
wordTimestampsbooleanNoWhen true, the response includes a wordTimestamps array of {word, start, end, confidence} entries (times in seconds). confidence runs from 0 to 1, and a low value marks a word the model was unsure of; it is null for languages without word confidence. Default false; adds latency.
diarizebooleanNoWhen true, the response adds speakerSegments (an array of {speaker, start, end} turns, seconds) and speakerCount. If wordTimestamps is also requested, each word gains a speaker id. Default false; adds latency.
retainTranscriptbooleanNofalse keeps nothing of this call: the transcript is returned and not stored, whatever your account's transcript retention. Omitted or true: your retention (Settings) applies.

Response (200)

JSON
{
  "transcript": "አንድነታችን ነው፣ ጸንቶ የሚያቆመን",
  "localeCode": "amh",
  "latencyMs": 612,
  "audioDurationMs": 3580,
  "confidence": 0.95,
  "wordTimestamps": [
    { "word": "አንድነታችን", "start": 0.20, "end": 1.10, "confidence": 0.94, "speaker": 0 },
    { "word": "ነው",       "start": 1.24, "end": 1.56, "confidence": 0.97, "speaker": 0 },
    { "word": "ጸንቶ",      "start": 1.74, "end": 2.22, "confidence": 0.61, "speaker": 1 },
    { "word": "የሚያቆመን",  "start": 2.36, "end": 3.42, "confidence": 0.88, "speaker": 1 }
  ],
  "speakerSegments": [
    { "speaker": 0, "start": 0.20, "end": 1.56 },
    { "speaker": 1, "start": 1.74, "end": 3.42 }
  ],
  "speakerCount": 2,
  "transcriptRetainedUntil": "2026-10-29T14:03:11.000Z"
}

wordTimestamps is present only when the request asks for it. speakerSegments and speakerCount are present only when diarize is requested; the per-word speaker id appears only when both wordTimestamps and diarize are requested. All other fields are always present on a 2xx response. transcriptRetainedUntil says until when we keep this transcript; null means we kept nothing (your retention is off, or the request sent retainTranscript: false).

Examples

curl -X POST https://api.phonetico.ai/v1/asr \
  -H "Authorization: Bearer pk_live_a1b2c3d4e5f67890" \
  -H "Content-Type: application/json" \
  -d '{
    "audio": "'$(base64 -i speech.wav)'",
    "localeCode": "amh"
  }'

POST /v1/asr/upload

Same model, same response shape. The audio is sent as a regular file upload instead of base64-encoded JSON. Easier from cURL, browser FormData, and tools that prefer multipart bodies.

HTTP
POST https://api.phonetico.ai/v1/asr/upload
Authorization: Bearer pk_live_your_key
Content-Type: multipart/form-data

Form fields

FieldTypeRequiredDescription
audiofileYesAudio file. Supports WAV, WebM, OGG, MP3, FLAC.
localeCodestringYesAs on the JSON endpoint: a language code, or auto with model named.
modelstringWith autoAs on the JSON endpoint: a model name from GET /v1/models?service=asr.
wordTimestampsstringNoSend the literal string "true" (or "1") to enable word timestamps. Multipart can't carry a JSON boolean.
diarizestringNoSend the literal string "true" (or "1") to add speakerSegments and speakerCount to the response, same as the JSON endpoint's diarize flag.
retainTranscriptstringNoSend "false" (or "0") to keep nothing of this call, same as the JSON endpoint's retainTranscript.
bash
curl -X POST https://api.phonetico.ai/v1/asr/upload \
  -H "Authorization: Bearer pk_live_your_key" \
  -F "[email protected]" \
  -F "localeCode=amh" \
  -F "wordTimestamps=true"

GET /v1/asr/limits

Returns the current operator-tunable limits. Public, no auth required. Useful for clients that want to validate audio size or duration before submitting.

JSON
{
  "maxFileSizeBytes": 15728640,
  "maxAudioDurationSeconds": 300,
  "requestsPerMinute": 10,
  "requestsPerHour": 120,
  "upstreamTimeoutMs": 90000,
  "streamInitTimeoutSeconds": 5,
  "maxActiveKeysPerAccount": 5,
  "maxKeyExpiryDays": 730,
  "keyRotationGraceMinutes": 1440,
  "maxActiveJobsPerAccount": 5,
  "jobMaxDurationSeconds": 14400,
  "jobMaxUploadBytes": 5368709120,
  "jobUploadPartBytes": 33554432,
  "jobUploadWindowHours": 72,
  "jobResultPickupHours": 24,
  "webhookTimeoutSeconds": 10,
  "webhookMaxAttempts": 8,
  "webhookFirstRetrySeconds": 30
}

Limits

Read live from GET /v1/asr/limits, the values the API enforces now:

LimitValueNotes
Max audio file size15 MBLarger uploads return 413.
Max audio duration300 sPer request or stream.
Requests per minute10Per API key, across every endpoint; a stream open counts as one.
Requests per hour120Per API key.
Upstream inference timeout90 sLong calls return 504 with reason: upstream_timeout.
Streaming init deadline5 sSend init within this after opening the socket, or it closes 4001.
Active API keys5Per account (maxActiveKeysPerAccount); revoke a key to create another.
Longest key expiry730 daysThe furthest expiry a new key may have (maxKeyExpiryDays); a key may also have none.
Rotation grace1440 minThe old secret keeps working this long after a rotation, so servers can switch without downtime (keyRotationGraceMinutes).
Unfinished jobs5Per account (maxActiveJobsPerAccount): waiting for upload, queued or running. One more returns 429.
Longest job recording4 hjobMaxDurationSeconds. A longer recording fails the job with recording_too_long before any cost is held.
Largest job recording5 GiBjobMaxUploadBytes. A larger upload or URL download fails the job with upload_too_large.
Job upload part size32 MiBjobUploadPartBytes: every part but the last is this size.
Job upload window72 hjobUploadWindowHours. A job whose upload is not completed in time fails with upload_abandoned.
Job result pickup window24 hjobResultPickupHours. How long a result is kept when your account keeps no transcripts or the job set retainTranscript: false.
Webhook response wait10 swebhookTimeoutSeconds. No response within it counts as a failed delivery.
Webhook attempts8webhookMaxAttempts, the first retry after 30 s (webhookFirstRetrySeconds), each later one waiting twice as long.

Headroom in every response

Calls made with a developer key say how much room is left, so a client can slow down before it is refused:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the window nearest its limit: this minute, or this hour.
X-RateLimit-RemainingRequests left in that window.
X-RateLimit-ResetWhen that window ends, in Unix seconds.
X-Jobs-LimitOn transcription job calls: unfinished jobs your account may have (maxActiveJobsPerAccount).
X-Jobs-RemainingJobs you can still create now, after this call.
Retry-AfterOn a 429 (over a rate limit) or a 503 upstream_busy: seconds to wait before retrying.

Failure responses

When a call fails, the response body carries a classified reason and a human-readable detail. The same usageId is returned so you can cross-reference the usage history.

JSON
{
  "error": "ASRServiceError",
  "code": "asr_service_error",
  "reason": "upstream_timeout",
  "detail": "Upstream inference timed out after 120000ms (limit 120000ms).",
  "usageId": "47cdb885-8f03-4079-9c3a-cce2392d8ab9"
}

The full failure taxonomy and HTTP status mapping lives on the Error Codes page.

Transcription jobs

For recordings of any length, up to the limits above, and for video files: interviews, hearings, broadcasts. A job takes the recording as an upload in parts or as a public URL, runs in the background, and keeps its result for you to fetch. POST /v1/asr stays the way to transcribe short clips in one request.

  1. POST /v1/asr/jobs creates the job.
  2. For an upload: ask for part URLs, PUT each part to its URL, then complete the upload. A URL job skips this.
  3. Poll GET /v1/asr/jobs/:id until status is succeeded or failed.
  4. Fetch GET /v1/asr/jobs/:id/result as JSON, TXT, SRT, VTT or DOCX.

Every call uses your API key (Authorization: Bearer pk_live_…). Long recordings must be offered for the language, and speaker labels and word timings for the options you ask for; see Languages. The cost is held when the job starts running, for the recording's length, and charged when it succeeds; a failed or cancelled job costs nothing.

POST /v1/asr/jobs

FieldMeaning
localeCodeLanguage of the recording, e.g. amh or tir.
nameOptional label the job is listed by, such as the file’s name: up to 255 characters, line breaks become spaces.
source{ "kind": "upload" } to upload the file, or { "kind": "url", "url": "https://…" } for a public http(s) address. Addresses on private networks are refused.
options.wordTimestampsStart and end of every word.
options.diarizeSpeaker labels over the whole recording; implies word timings.
options.retainTranscriptfalse keeps the result only for the pickup window, as when your account keeps no transcripts.
webhookUrlOptional public http(s) address to receive a signed POST when the job succeeds or fails; see Webhooks.
translationOptional, where offered: a professional translation of the transcript. targets lists the languages to translate into, shareAudio says whether the recording may go to the translator as well as the transcript, notes is free text for the translator, and consentVersion names the version of the translation consent you accepted. We keep the recording and transcript, encrypted, until the translation is delivered, then destroy them. See Translation options.

Returns 201 with job and, for an upload, upload: the size limit, the part size and the most parts allowed.

bash
curl -X POST https://api.phonetico.ai/v1/asr/jobs \
  -H "Authorization: Bearer $PHONETICO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"localeCode": "tir", "options": {"diarize": true}, "source": {"kind": "upload"}}'

GET /v1/asr/jobs/translation-options

Pass localeCode, the language of the recording. The answer says whether professional translation is offered for it, and if not, the reason. When it is offered, targets lists the languages you can ask for, each with a code, a name and its price: at most centsPerMinute for each audio minute, or minimumCents when that is more. Pass audioDurationMs as well and each target carries estimateCents, the price for that length now; the quote held when the job finishes uses the partners' prices at that moment. schedule gives dueDays, how long the translator has once assigned, and graceDays, how long the recording and transcript stay after the translation is completed. consent names the consent document with its title, version, effectiveDate and url. Show it to the person asking, and send its version as consentVersion.

Uploading the recording

Split the file into parts of partBytes (the last part may be smaller). Ask for URLs with POST /v1/asr/jobs/:id/upload/parts and { "partNumbers": [1, 2, 3] } (up to 100 per request), then PUT each part's bytes to its url before expiresAt. If a connection drops, GET /v1/asr/jobs/:id/upload/parts lists the parts already stored with their size, so you resend only the rest. Then POST /v1/asr/jobs/:id/upload/complete: the API reads the stored parts itself, checks the size and queues the job. Complete the upload within 72 hours of creating the job (jobUploadWindowHours); after that the job fails as upload_abandoned, its parts are deleted and it no longer counts toward your unfinished jobs.

The job

FieldMeaning
jobIdIts id.
statusawaiting_upload, queued, running, succeeded, failed or cancelled.
progressFrom 0 to 1 while running.
audioDurationMsThe recording’s length, once measured.
errorcode and message when it failed.
name, localeCode, options, sourceKind, webhookUrlAs submitted.
createdAt, queuedAt, startedAt, finishedAtWhen each stage began.
deletedAtWhen you deleted its result.
transcriptRetainedUntilWhen the result is deleted; null before there is one and once it is gone.
costOnce the job has run: amount, the dollars charged, and freeMinutes, the free minutes it used. null before then.
translationTargetsLanguages a professional translation was asked into, in the order asked.
translationOfThe finished job whose recording this job re-uploads for a translation asked afterwards; null otherwise.

GET /v1/asr/jobs lists your jobs, newest first, limit at a time (at most 100). Pass nextCursor as before for the next page; nextCursor is null on the last one.

POST /v1/asr/jobs/:id/translation

Asks for a professional translation of a job that has finished, with the same fields as the translation block above. When the job's transcript is still kept and shareAudio is false, nothing more is needed: the answer lists the requests made and job is null. Otherwise we need the recording again, because we delete it once a job finishes: the answer carries a new job (its translationOf names the finished one) and its upload limits. Upload the same recording to it as above. We check that it is the same recording before anything is charged, and reuse the finished job's transcript while it is kept; if it is not kept, the recording is transcribed again at the usual price. A job that finished before recordings were fingerprinted is refused (job_state); submit its recording as a new job with a translation.

GET /v1/asr/jobs/:id/translations

Returns translations, one per target language asked for, on the job and on a re-upload job made for it: requestId, jobId (the job that carries it), targetLanguage, targetName, status, audioPermitted, quotedCents, chargedCents, requestedAt, endedAt, error (when failed) and file. A translation is in_progress until our translation staff have checked the translator's work and released it, then completed; it can also end cancelled or failed. A completed translation has a file with its fileName, contentType and retainedUntil: the translation is kept as your transcripts are (your account's retention, or the pickup window when the job or account keeps nothing), then deleted, and a download after that answers translation_gone.

A translation is priced once the recording's length is known, when the job finishes: the translator's rate per audio minute (or their minimum) plus our margin. That quotedCents is held on your balance until the translation ends, and is charged when it is completed (chargedCents). Free minutes do not apply. Nothing is charged for a translation that is cancelled or fails. When the balance available, or the API key's monthly spend cap, does not cover the price, the translation fails at once with insufficient_balance or key_spend_cap and nothing of the recording is kept for it; no_partner means no translator covers the language pair any more, and length_unknown that the recording's length could not be read to price it.

GET /v1/asr/jobs/:id/translations/:requestId/file downloads a completed translation as the translator returned it. Anything else answers translation_not_found. The job's webhook announces each translation's end (below), so you need not poll.

Results

GET /v1/asr/jobs/:id/result?format=json (the default) returns transcript, words (each with start, end, confidence and, with speaker labels, speaker; a low confidence marks a word the model was unsure of, and languages without word confidence give null), speakers (the turns), speakerNames, audioDurationMs and retainedUntil. txt gives the text, as speaker paragraphs when labelled; docx the same as a Word document; srt and vtt subtitles, which need word timings or speaker labels. Speakers are numbered from 1 in text and subtitles unless named.

PUT /v1/asr/jobs/:id/speakers with {"speakerNames": {"0": "Interviewer", "1": "Ato Kebede"}} names them: keys are the 0-based ids in words[].speaker, names are trimmed with line breaks removed, at most 80 characters, and an empty name restores the number. It replaces the whole set and returns the result. Every format then carries the names. They are part of the result, so they are deleted with it.

A result is kept for your account's transcript retention, or for the pickup window (24 hours, jobResultPickupHours) when your account keeps none or the job asked to keep nothing. After that, or after DELETE /v1/asr/jobs/:id, it answers 410. Deleting keeps the job itself as the record of what was charged. POST /v1/asr/jobs/:id/cancel stops an unfinished job and deletes its recording.

Webhooks

A job created with webhookUrl gets one POST to that address when it succeeds or fails, so you need not poll. The body names the job and never carries the transcript; fetch the result with your API key.

JSON
{
  "event": "job.succeeded",
  "jobId": "…",
  "status": "succeeded",
  "error": null,
  "finishedAt": "2026-09-29T18:04:11.000Z"
}

When the job asked for a professional translation, the same address gets one POST per target language when that translation ends: translation.completed (ready to download), translation.cancelled or translation.failed. The body carries no translation; download it as above.

JSON
{
  "event": "translation.completed",
  "jobId": "…",
  "requestId": "…",
  "targetLanguage": "eng",
  "status": "completed",
  "error": null,
  "endedAt": "2026-10-06T09:12:40.000Z"
}
HeaderMeaning
Phonetico-Eventjob.succeeded, job.failed, translation.completed, translation.cancelled or translation.failed.
Phonetico-DeliveryThe job id (or, for a translation, its requestId) and the event. The same on every retry, so a repeat can be ignored.
Phonetico-Signaturet= the send time in Unix seconds, v1= the hex HMAC-SHA256 of "<t>.<body>" keyed with your signing secret.

Your signing secret is in Settings, Webhooks. Check the signature against the raw body before trusting a delivery, and refuse an old t so a captured delivery cannot be replayed:

javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the request body exactly as received, before JSON parsing.
function verify(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Answer with any 2xx status. Anything else, or no response within 10 seconds, is tried again: up to 8 attempts, the first retry after 30 seconds and each later one waiting twice as long. The address must stay public: one that resolves to a private network when sent is refused and not retried. Rotating the secret in Settings stops the old one verifying at once, retries included.

GET /v1/asr/jobs/:id/webhook-deliveries returns deliveries, every attempt oldest first: deliveryId, event, requestId (a translation event's request, null otherwise), url, attempt, statusCode (null when no response came), error, responseExcerpt (the first 500 characters your endpoint answered) and createdAt.

Job error codes

CodeWhen
job_limit_reached (429)Your account already has the most unfinished jobs allowed.
url_not_public (400)The source URL or webhookUrl is not a public http(s) address.
translation_unavailable (503)Professional translation is not offered for this language yet; nothing was created.
translation_invalid (400)No target language, a target not offered for this language (the message lists the offered ones), or notes that are too long.
translation_consent_changed (409)The consent version sent is not the current one. Show the current version and send it again.
translation_requires_job (400)A translation was asked of POST /v1/asr or /v1/asr/upload; ask for it on a job.
upload_incomplete (400)Nothing uploaded, a part missing, or a part too small.
job_state (409)The job is not in a state the call applies to.
result_has_no_timings (409)SRT or VTT asked of a job without word timings.
result_has_no_speakers (409)Speaker names sent for a result without speaker labels.
invalid_speaker_names (400)A speaker id the result does not have, or a name over 80 characters.
result_gone (410)The result was deleted or passed its retention.
translation_not_found (404)No completed translation with that requestId on this job.
translation_gone (410)The completed translation was deleted at the end of its retention.
recording_too_longFailed job: longer than the longest recording accepted.
upload_too_largeFailed job: larger than the largest recording accepted.
insufficient_balanceFailed job: free minutes and balance do not cover it.
recording_mismatchFailed job: a translation re-upload that is not the recording of the job it was asked of. Nothing was charged.
key_spend_capFailed job: its cost would take the submitting key past its monthly spend cap.
audio_decode_errorFailed job: the file could not be read as audio or video.
upload_abandonedFailed job: the upload was not completed within the upload window.
diarization_failed, transcription_failedFailed job: the service kept failing; submit it again.

POST /v1/tts

Synthesize text into audio. Today only Tigrinya (tir) has a deployed finetune; other locales return 503 until their models land.

Three voice modes, exactly one per call:

  • Preset. Pass voice.preset with a voice ID from GET /v1/tts/voices. The model uses the voice's stored reference clip; no attestation needed because consent travelled with the voice when it was added.
  • Saved. Pass voice.saved with the ID of a voice your account saved in Speak (New voice), listed by GET /v1/tts/saved-voices. Its clip and transcript are stored with it and consent was recorded when it was saved, so no attestation is needed. It may read a language other than the one in its clip; the clip's accent carries.
  • Custom-reference. Pass voice.reference with audio + transcript + rightsAttestation: true. Gated by a one-time per-key custom-voice attestation set on /keys; calls return 412 with code: attestation_required until accepted.
HTTP
POST https://api.phonetico.ai/v1/tts
Authorization: Bearer pk_live_your_key
Content-Type: application/json

Request body

FieldTypeDescription
textstringText to synthesize. NFC-normalized Ge'ez recommended. Max length read from tts.limits.max_text_length_chars (default 500).
languagestringLocale code to speak in, one of the model's languages (GET /v1/models). Required for a model that serves several.
modelstringThe model to synthesize with: phonetico-speak-2, phonetico-speak-1, or phonetico-speak for the latest. A model that does not serve language is refused. Omitting it is deprecated: the language's default model answers, and the response names it in model.
voice.presetstring?Voice ID from GET /v1/tts/voices. Send exactly one of voice.preset, voice.saved and voice.reference in a call.
voice.savedstring?ID of one of your saved voices (GET /v1/tts/saved-voices). Another account's voice, or a deleted one, answers 404 voice_not_active. Device keys cannot use saved voices.
voice.reference.audiostring (base64)5-15s of clean speech from the voice to clone. WAV/Opus/MP3.
voice.reference.transcriptstringExact transcript of the reference clip. Mismatched transcripts distort output.
voice.reference.rightsAttestationtrueMust be true. Per-key attestation is set once at /keys; calls 412 with attestation_required until accepted.
options.qualityTier'fast' | 'balanced' | 'quality'Only for a model that lists controls.qualityTiers in GET /v1/models (Speak 1). Higher tiers take longer and sound closer to a natural reading; cost scales 1× / 2× / 3×. Default balanced.
options.instructionstring?How the text should be spoken, in English ("a campaign speech, with emotion and dismissive bravado"). Only for a model that lists controls.delivery (Speak 2), up to its maxInstructionChars. The voice stays the one in the sample. Does not change the cost.
options.delivery'directed' | 'natural'How closely the instruction is followed; sent only with options.instruction. directed (the default) follows it closely and can turn sing-song late in a long piece. natural stays closer to the voice sample and may add hesitations. A preset without an instruction returns 400 with code: option_out_of_range.
options.seednumber?Deterministic generation. Same seed + same inputs = same output.
options.speednumber?Speaking pace. 1.0 (the default) is the voice's own pace; 0.8 is slower, 1.2 faster. Allowed: 0.8 to 1.2; outside it returns 400 with code: speed_out_of_range. Does not change the cost.

Response

JSON
{
  "model": "phonetico-speak-1",
  "audio": "<base64 WAV bytes>",
  "format": "wav",
  "sampleRate": 24000,
  "outputAudioDurationMs": 820,
  "latencyMs": 6240,
  "rtf": 7.61,
  "voice": "custom",
  "seed": 195950210,
  "qualityTier": "balanced",
  "delivery": null
}

delivery is the preset followed, or null without an instruction.

Speak 2 reads best from a voice sample of about 20 to 25 seconds with its exact transcript, and from text sent in pieces of about 25 to 40 seconds of speech, each ending at a sentence. A longer piece can drift into a sing-song reading; a shorter one can sound disjointed. The API synthesizes the text it is sent as one piece.

Cost formula

cost = max(baseCharge, chars × perCharacter) × tierMultiplier

Each model has its own perCharacter price and baseCharge minimum; the multiplier is 1 for a model without quality tiers. The price of every model is in rates.models of GET /v1/pricing, and on Pricing.

Speech jobs

For any text up to the length limit, however long it takes to synthesize. A job answers at once, synthesizes in the background, and keeps the audio for you to fetch, so nothing waits on an open connection. POST /v1/tts answers with the audio in one request and suits short text. Synthesis in a job may take up to 600 seconds; an account may have 5 jobs queued or running at once.

  1. POST /v1/tts/jobs creates the job; it answers 201 with the job, status queued.
  2. Poll GET /v1/tts/jobs/:id until status is succeeded or failed, or receive the webhook.
  3. Fetch GET /v1/tts/jobs/:id/audio: the WAV.

The body is the POST /v1/tts body, with two more fields. The price is checked against your balance when you submit (402 insufficient_balance when it does not cover the text), held when the job starts running, and charged when it succeeds; a failed or cancelled job costs nothing.

POST /v1/tts/jobs

FieldMeaning
model, text, language, voiceAs POST /v1/tts.
optionsAs POST /v1/tts (qualityTier, seed, speed, instruction, delivery), plus retainAudio.
options.retainAudiofalse keeps the audio only for the pickup window, as when your account keeps no transcripts.
nameOptional label the job is listed by: up to 255 characters.
webhookUrlOptional public http(s) address to receive a signed POST when the job succeeds or fails: speech.succeeded or speech.failed, signed as the transcription job webhooks are.
bash
curl -X POST https://api.phonetico.ai/v1/tts/jobs \
  -H "Authorization: Bearer pk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"model": "phonetico-speak-1", "language": "tir", "text": "…",
       "voice": {"preset": "tir_phonetico_01"}, "options": {"qualityTier": "fast"}}'

The job

FieldMeaning
jobIdThe job’s id.
statusqueued, running, succeeded, failed or cancelled.
modelPublic name of the model it runs on.
nameThe label sent, or null.
language, textWhat was asked for.
charactersThe characters billed.
optionsThe quality tier used (null for a model without tiers), seed, speed, the instruction and delivery preset when one was sent, retainAudio.
voice{ "kind": "preset", "voiceId": … } or { "kind": "reference" } for a clip sent with it.
errorWhen it failed: a code and a message saying what to do.
createdAt, queuedAt, startedAt, finishedAtWhen it reached each stage.
deletedAtWhen you deleted its audio.
webhookUrlWhere its webhook goes, or null.
audioOnce it succeeded and while kept: durationMs, sampleRate, seed and retainedUntil. Null otherwise.
costWhat it was charged, once it succeeded: { "amount": dollars }.

The other endpoints

EndpointWhat it does
GET /v1/tts/jobsYour jobs, newest first: jobs and nextCursor (pass it as before).
GET /v1/tts/jobs/countsJobs queued or running now (active), created this month (thisMonth), and the limit.
GET /v1/tts/jobs/:idOne job.
GET /v1/tts/jobs/:id/audio409 before the job succeeds; 410 once its audio is deleted or past retention.
POST /v1/tts/jobs/:id/cancelAn unfinished job; 409 once it has ended.
DELETE /v1/tts/jobs/:idAn ended job’s audio. The job stays as the record of its charge.
GET /v1/tts/jobs/:id/webhook-deliveriesEach attempt to deliver its webhook.

Audio is kept for your transcript retention (Settings), or 24 hours when you keep none or the job asked to keep none. Every response carries X-Jobs-Limit and X-Jobs-Remaining; a job past the limit is refused 429 job_limit_reached.

GET /v1/tts/voices

Lists every active preset voice in the catalog. Optional language filter narrows by ISO 639-3 code. The consentScope field on each voice is descriptive metadata — every active voice is callable by any key (consent is captured at contribution time, not enforced at request time).

HTTP
GET https://api.phonetico.ai/v1/tts/voices?language=tir
Authorization: Bearer pk_live_your_key

Response

JSON
{
  "voices": [
    {
      "voiceId": "tir_phonetico_01",
      "displayName": "Tigrinya Speaker 1",
      "language": "tir",
      "gender": "female",
      "accentRegion": null,
      "consentScope": "commercial"
    }
  ]
}

GET /v1/tts/saved-voices

The voices your account saved, newest first. A voice is saved in Speak: you upload or record a clip, correct its transcript, choose its language and name, and agree to the voice consent once. Each voice lists its voiceId, name, the language of its clip, the transcript of the clip, an optional description, whose voice it is as speaker (self or other, null for a voice saved before this was asked), clipDurationMs, createdAt, and the consent given: the documentVersion agreed to and agreedAt. Pass the voiceId as voice.saved to synthesize with it. Developer keys only.

HTTP
GET https://api.phonetico.ai/v1/tts/saved-voices
Authorization: Bearer pk_live_your_key

Response

JSON
{
  "voices": [
    {
      "voiceId": "6f1c2a8e-3b44-4d7e-9a51-0c2e7d9b1f30",
      "name": "Narrator",
      "language": "amh",
      "transcript": "…",
      "description": "Warm, steady, for narration",
      "speaker": "self",
      "clipDurationMs": 21400,
      "createdAt": "2026-10-21T09:12:44.000Z",
      "consent": { "documentVersion": 1, "agreedAt": "2026-10-21T09:12:44.000Z" }
    }
  ]
}

GET /v1/tts/voices/:id/preview

A preset voice reading one fixed sentence in its language, as audio/wav, so you can hear a voice before you choose it. Public, no auth, not charged. Pass model (a public name or alias) to hear it on that model; without it, the language's default. Pass tier to hear one of the model's quality tiers; without it, the model's default tier. A model without tiers refuses tier with 400. The model and tier that read it are in the X-Model and X-Quality-Tier headers. A language with no preview sentence answers 404 preview_not_offered; an unknown or withdrawn voice 404 voice_not_active. GET /v1/tts/voices/previews lists the voices that have a preview and the sentence each reads.

bash
curl -o voice.wav "https://api.phonetico.ai/v1/tts/voices/explain-female/preview?model=phonetico-speak-1&tier=quality"

GET /v1/tts/limits

Returns the current operator-tunable TTS limits. Public, no auth required. Mobile clients and device-key consumers can call this on launch to keep their local cap copy in sync with the server's.

JSON
{
  "model": null,
  "maxTextLengthChars": 1500,
  "absoluteMaxTextLengthChars": 5000,
  "maxReferenceAudioBytes": 3145728,
  "absoluteMaxReferenceAudioBytes": 20971520,
  "upstreamTimeoutMs": 90000,
  "freeTierMonthlyChars": 1500,
  "premiumTierMonthlyChars": 50000,
  "premiumTierDailyChars": 5000,
  "deviceMaxReferenceAudioSeconds": 11,
  "speedMin": 0.8,
  "speedMax": 1.2,
  "speedStep": 0.05,
  "maxActiveJobsPerAccount": 5,
  "jobMaxSynthesisSeconds": 600,
  "jobUnretainedAudioHours": 24
}

TTS limits

Read live from GET /v1/tts/limits and GET /v1/asr/limits, the values the API enforces now:

LimitNowNotes
Max text length per call1,500 charsmaxTextLengthChars; the most it can be set to is 5,000. Longer text returns 413 with code: text_too_long.
Custom-voice reference clip3.0 MBDecoded size of voice.reference.audio (maxReferenceAudioBytes). A larger clip returns 413 with code: reference_audio_too_large. The model keeps at most 12 s of it, so a 5 to 15 second mono clip is all it needs.
Requests per minute10Per key, shared with ASR (requestsPerMinute on GET /v1/asr/limits).
Upstream inference timeout90 sLong calls return 504 with code: upstream_timeout.

Device-key character quotas

Device keys (Phonetico's own mobile apps, prefix dk_live_) use a free-tier monthly cap with a premium upgrade path. Developer keys (pk_live_) are unaffected. They meter against the dollar balance.

EntitlementPer requestMonthly charsDaily charsQuality tier
Free1,5001,500—Fast / Balanced only
Premium1,50050,0005,000All tiers

Over-cap calls return 429 with code: quota_exceeded (monthly) or code: daily_limit_reached (premium daily safety net). Free-tier requests forqualityTier: "quality" return 403 with code: quality_tier_premium_only. Pre-flight rejections do not consume from your quota.

TTS failure responses

TTS failures carry the classified code plus a human-readable message. The full taxonomy and HTTP-status mapping is on the Error Codes page.

JSON
{
  "error": "QuotaExceeded",
  "code": "quota_exceeded",
  "message": "Monthly free character limit reached. Upgrade for more."
}