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
| Method | Path | Body | Use for |
|---|---|---|---|
| POST | /v1/asr | JSON (base64 audio) | Programmatic clients with audio already in memory. |
| POST | /v1/asr/upload | multipart/form-data | Direct 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/jobs | JSON; the recording in parts or by URL | Recordings of any length, and video: a job in the background, with the result as JSON, TXT, SRT, VTT or DOCX. |
| POST | /v1/tts | JSON (text + voice spec) | Text-to-speech synthesis, in a preset voice or from your own reference clip. |
| POST | /v1/tts/jobs | JSON (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.
POST https://api.phonetico.ai/v1/asr
Authorization: Bearer pk_live_your_key
Content-Type: application/jsonRequest body
| Field | Type | Required | Description |
|---|---|---|---|
| audio | string | Yes | Base64-encoded audio. Supports WAV, WebM, OGG, MP3, FLAC, and raw PCM Int16. |
| localeCode | string | Yes | Language 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. |
| model | string | With auto | The 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. |
| wordTimestamps | boolean | No | When 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. |
| diarize | boolean | No | When 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. |
| retainTranscript | boolean | No | false 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)
{
"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.
POST https://api.phonetico.ai/v1/asr/upload
Authorization: Bearer pk_live_your_key
Content-Type: multipart/form-dataForm fields
| Field | Type | Required | Description |
|---|---|---|---|
| audio | file | Yes | Audio file. Supports WAV, WebM, OGG, MP3, FLAC. |
| localeCode | string | Yes | As on the JSON endpoint: a language code, or auto with model named. |
| model | string | With auto | As on the JSON endpoint: a model name from GET /v1/models?service=asr. |
| wordTimestamps | string | No | Send the literal string "true" (or "1") to enable word timestamps. Multipart can't carry a JSON boolean. |
| diarize | string | No | Send the literal string "true" (or "1") to add speakerSegments and speakerCount to the response, same as the JSON endpoint's diarize flag. |
| retainTranscript | string | No | Send "false" (or "0") to keep nothing of this call, same as the JSON endpoint's retainTranscript. |
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.
{
"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:
| Limit | Value | Notes |
|---|---|---|
| Max audio file size | 15 MB | Larger uploads return 413. |
| Max audio duration | 300 s | Per request or stream. |
| Requests per minute | 10 | Per API key, across every endpoint; a stream open counts as one. |
| Requests per hour | 120 | Per API key. |
| Upstream inference timeout | 90 s | Long calls return 504 with reason: upstream_timeout. |
| Streaming init deadline | 5 s | Send init within this after opening the socket, or it closes 4001. |
| Active API keys | 5 | Per account (maxActiveKeysPerAccount); revoke a key to create another. |
| Longest key expiry | 730 days | The furthest expiry a new key may have (maxKeyExpiryDays); a key may also have none. |
| Rotation grace | 1440 min | The old secret keeps working this long after a rotation, so servers can switch without downtime (keyRotationGraceMinutes). |
| Unfinished jobs | 5 | Per account (maxActiveJobsPerAccount): waiting for upload, queued or running. One more returns 429. |
| Longest job recording | 4 h | jobMaxDurationSeconds. A longer recording fails the job with recording_too_long before any cost is held. |
| Largest job recording | 5 GiB | jobMaxUploadBytes. A larger upload or URL download fails the job with upload_too_large. |
| Job upload part size | 32 MiB | jobUploadPartBytes: every part but the last is this size. |
| Job upload window | 72 h | jobUploadWindowHours. A job whose upload is not completed in time fails with upload_abandoned. |
| Job result pickup window | 24 h | jobResultPickupHours. How long a result is kept when your account keeps no transcripts or the job set retainTranscript: false. |
| Webhook response wait | 10 s | webhookTimeoutSeconds. No response within it counts as a failed delivery. |
| Webhook attempts | 8 | webhookMaxAttempts, 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:
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Requests allowed in the window nearest its limit: this minute, or this hour. |
| X-RateLimit-Remaining | Requests left in that window. |
| X-RateLimit-Reset | When that window ends, in Unix seconds. |
| X-Jobs-Limit | On transcription job calls: unfinished jobs your account may have (maxActiveJobsPerAccount). |
| X-Jobs-Remaining | Jobs you can still create now, after this call. |
| Retry-After | On 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.
{
"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.
POST /v1/asr/jobscreates the job.- For an upload: ask for part URLs,
PUTeach part to its URL, then complete the upload. A URL job skips this. - Poll
GET /v1/asr/jobs/:iduntilstatusissucceededorfailed. - Fetch
GET /v1/asr/jobs/:id/resultas 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
| Field | Meaning |
|---|---|
| localeCode | Language of the recording, e.g. amh or tir. |
| name | Optional 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.wordTimestamps | Start and end of every word. |
| options.diarize | Speaker labels over the whole recording; implies word timings. |
| options.retainTranscript | false keeps the result only for the pickup window, as when your account keeps no transcripts. |
| webhookUrl | Optional public http(s) address to receive a signed POST when the job succeeds or fails; see Webhooks. |
| translation | Optional, 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.
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
| Field | Meaning |
|---|---|
| jobId | Its id. |
| status | awaiting_upload, queued, running, succeeded, failed or cancelled. |
| progress | From 0 to 1 while running. |
| audioDurationMs | The recording’s length, once measured. |
| error | code and message when it failed. |
| name, localeCode, options, sourceKind, webhookUrl | As submitted. |
| createdAt, queuedAt, startedAt, finishedAt | When each stage began. |
| deletedAt | When you deleted its result. |
| transcriptRetainedUntil | When the result is deleted; null before there is one and once it is gone. |
| cost | Once the job has run: amount, the dollars charged, and freeMinutes, the free minutes it used. null before then. |
| translationTargets | Languages a professional translation was asked into, in the order asked. |
| translationOf | The 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.
{
"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.
{
"event": "translation.completed",
"jobId": "…",
"requestId": "…",
"targetLanguage": "eng",
"status": "completed",
"error": null,
"endedAt": "2026-10-06T09:12:40.000Z"
}| Header | Meaning |
|---|---|
| Phonetico-Event | job.succeeded, job.failed, translation.completed, translation.cancelled or translation.failed. |
| Phonetico-Delivery | The job id (or, for a translation, its requestId) and the event. The same on every retry, so a repeat can be ignored. |
| Phonetico-Signature | t= 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:
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
| Code | When |
|---|---|
| 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_long | Failed job: longer than the longest recording accepted. |
| upload_too_large | Failed job: larger than the largest recording accepted. |
| insufficient_balance | Failed job: free minutes and balance do not cover it. |
| recording_mismatch | Failed job: a translation re-upload that is not the recording of the job it was asked of. Nothing was charged. |
| key_spend_cap | Failed job: its cost would take the submitting key past its monthly spend cap. |
| audio_decode_error | Failed job: the file could not be read as audio or video. |
| upload_abandoned | Failed job: the upload was not completed within the upload window. |
| diarization_failed, transcription_failed | Failed 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.presetwith a voice ID fromGET /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.savedwith the ID of a voice your account saved in Speak (New voice), listed byGET /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.referencewith audio + transcript +rightsAttestation: true. Gated by a one-time per-key custom-voice attestation set on /keys; calls return 412 withcode: attestation_requireduntil accepted.
POST https://api.phonetico.ai/v1/tts
Authorization: Bearer pk_live_your_key
Content-Type: application/jsonRequest body
| Field | Type | Description |
|---|---|---|
| text | string | Text to synthesize. NFC-normalized Ge'ez recommended. Max length read from tts.limits.max_text_length_chars (default 500). |
| language | string | Locale code to speak in, one of the model's languages (GET /v1/models). Required for a model that serves several. |
| model | string | The 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.preset | string? | Voice ID from GET /v1/tts/voices. Send exactly one of voice.preset, voice.saved and voice.reference in a call. |
| voice.saved | string? | 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.audio | string (base64) | 5-15s of clean speech from the voice to clone. WAV/Opus/MP3. |
| voice.reference.transcript | string | Exact transcript of the reference clip. Mismatched transcripts distort output. |
| voice.reference.rightsAttestation | true | Must 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.instruction | string? | 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.seed | number? | Deterministic generation. Same seed + same inputs = same output. |
| options.speed | number? | 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
{
"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.
POST /v1/tts/jobscreates the job; it answers201with the job, statusqueued.- Poll
GET /v1/tts/jobs/:iduntilstatusissucceededorfailed, or receive the webhook. - 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
| Field | Meaning |
|---|---|
| model, text, language, voice | As POST /v1/tts. |
| options | As POST /v1/tts (qualityTier, seed, speed, instruction, delivery), plus retainAudio. |
| options.retainAudio | false keeps the audio only for the pickup window, as when your account keeps no transcripts. |
| name | Optional label the job is listed by: up to 255 characters. |
| webhookUrl | Optional 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. |
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
| Field | Meaning |
|---|---|
| jobId | The job’s id. |
| status | queued, running, succeeded, failed or cancelled. |
| model | Public name of the model it runs on. |
| name | The label sent, or null. |
| language, text | What was asked for. |
| characters | The characters billed. |
| options | The 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. |
| error | When it failed: a code and a message saying what to do. |
| createdAt, queuedAt, startedAt, finishedAt | When it reached each stage. |
| deletedAt | When you deleted its audio. |
| webhookUrl | Where its webhook goes, or null. |
| audio | Once it succeeded and while kept: durationMs, sampleRate, seed and retainedUntil. Null otherwise. |
| cost | What it was charged, once it succeeded: { "amount": dollars }. |
The other endpoints
| Endpoint | What it does |
|---|---|
| GET /v1/tts/jobs | Your jobs, newest first: jobs and nextCursor (pass it as before). |
| GET /v1/tts/jobs/counts | Jobs queued or running now (active), created this month (thisMonth), and the limit. |
| GET /v1/tts/jobs/:id | One job. |
| GET /v1/tts/jobs/:id/audio | 409 before the job succeeds; 410 once its audio is deleted or past retention. |
| POST /v1/tts/jobs/:id/cancel | An unfinished job; 409 once it has ended. |
| DELETE /v1/tts/jobs/:id | An ended job’s audio. The job stays as the record of its charge. |
| GET /v1/tts/jobs/:id/webhook-deliveries | Each 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).
GET https://api.phonetico.ai/v1/tts/voices?language=tir
Authorization: Bearer pk_live_your_keyResponse
{
"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.
GET https://api.phonetico.ai/v1/tts/saved-voices
Authorization: Bearer pk_live_your_keyResponse
{
"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.
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.
{
"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:
| Limit | Now | Notes |
|---|---|---|
| Max text length per call | 1,500 chars | maxTextLengthChars; the most it can be set to is 5,000. Longer text returns 413 with code: text_too_long. |
| Custom-voice reference clip | 3.0 MB | Decoded 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 minute | 10 | Per key, shared with ASR (requestsPerMinute on GET /v1/asr/limits). |
| Upstream inference timeout | 90 s | Long 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.
| Entitlement | Per request | Monthly chars | Daily chars | Quality tier |
|---|---|---|---|---|
| Free | 1,500 | 1,500 | — | Fast / Balanced only |
| Premium | 1,500 | 50,000 | 5,000 | All 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.
{
"error": "QuotaExceeded",
"code": "quota_exceeded",
"message": "Monthly free character limit reached. Upgrade for more."
}