# Verbi — synchronous speech-to-text API > Verbi transcribes speech in 25 European languages (auto-detected) and > returns the transcript in the same HTTP response — no job queue, no > polling. Typically 10×+ realtime: an hour-long file usually comes back in > about five minutes. Word-level timestamps + confidence and speaker > diarization included at no extra cost; free SRT/VTT captions with a > `?captions=` query parameter; a free custom-vocabulary glossary for > domain terms; video files accepted as-is. Built for agents and scripts. Base URL: https://verbi.services Auth: `Authorization: Bearer ` (mint one with `POST /v1/signup`). ## Quickstart # 1. get a key (free tier starts immediately; add an email for more hours) curl -s -X POST https://verbi.services/v1/signup \ -H 'Content-Type: application/json' -d '{"email":"you@example.com"}' # -> {"api_key":"vb_sk_...", ...} (verify the email to unlock the full grant) # 2. transcribe a file (any common format; response streams until done) curl -s -X POST https://verbi.services/v1/transcribe \ -H "Authorization: Bearer $KEY" \ --data-binary @episode.mp3 --max-time 900 # -> {"text":"...", "words":[...], "speakers":[...], "billed_speech_sec":...} ## Speed Transcription typically runs at 10×+ realtime end-to-end: a 15-minute file in about 90 seconds, a 60-minute file in about five minutes. The first response byte typically arrives in under a second; the response then streams keep-alive whitespace while decoding runs — keep the connection open and give your HTTP client a read timeout of at least 60 s between bytes, not a total timeout. Budget `audio_duration / 10` of wall-clock, but at least 30 s — short clips are dominated by fixed per-request overhead. Need it faster? `?diarize=false` skips speaker diarization: ~15% faster end-to-end. Use it whenever you only need the text, not who said it. It is the one real speed lever — everything else (confidence, timestamps) is computed regardless and costs nothing to keep. ## Free tier Every key starts with free speech-hours: - Anonymous key: 2 free speech-hours per month. - Email key: 10 free speech-hours per month + a one-time 20-hour welcome bonus (so 30 hours in your first month) — granted when you verify the address: signing up with an email sends a mail with a one-time link; until it is clicked the key carries the anonymous grant. Mail lost? `POST /v1/verify/resend` (with your key) mails a fresh link. Monthly hours reset every 30 days and unused ones do not carry over; the welcome bonus never expires. Your balance draws down monthly allowance → welcome bonus → paid credit. ## Pricing Pay for speech, not silence: the billing unit is the speech-hour — audio detected as speech and actually decoded, never raw file duration. Beyond the free tier, transcription is prepaid at a flat $0.05 per speech-hour: buy whole hours with `POST /v1/billing/topup`, minimum 20 hours ($1.00) per purchase. Payment is a hosted Stripe Checkout page — no card data ever touches Verbi — and the hours land on your balance when the payment completes. Every transcription reports `audio_duration_sec`, `billed_speech_sec`, `speech_ratio`, and the `vad_sensitivity` used — and exactly the reported `billed_speech_sec` is drawn from your balance, so every bill is auditable. The `vad_sensitivity` query parameter takes exactly one of two levels — `complete` (the default) or `economy`. Complete is the accurate meter: calibrated so billed speech tracks a human-timed speech reference, keeping quiet and messy speech (fail-open — it may bill slightly above pure speech rather than risk dropping any). Economy trims non-speech harder and is cheaper on media-heavy audio (film-style mixes, songs: roughly 6-15 percentage points less of runtime billed) at a completeness cost — quiet or effects-buried dialogue may be dropped. On clean speech (calls, meetings, dictation) the two levels bill and transcribe near-identically. Any other value, including a number, is rejected. Metering caveats, measured and honest: singing is billed only when the detector hears it as voice — clear dense vocals (rap, plain singing) bill and transcribe; harsh (screamed/growled) or heavily layered vocals are treated as music: not billed, but also not transcribed. Do not rely on Verbi for lyric transcription today. Instrumental music bills at roughly 0-4% of runtime. Two client notes worth coding for: - If your upload dies with a broken pipe (EPIPE), read the response before treating it as a transport error — the server drains the body and sends a real verdict (429 concurrency, 402 balance, 400 validation) even mid-upload. Strict writers that only check the write side miss it. - Very long files ride a single streamed response; a machine restart (a deploy, or platform host maintenance) can rarely cut the stream mid-way. Billing follows delivery, so a cut request costs nothing — retry it. ## Limits - Per-file cap: 60 minutes per synchronous call. - Concurrency: 1 in-flight transcription for anonymous keys, 2 for email keys. Requests beyond the limit are rejected immediately with 429 (and a `retry_after_sec` hint) — they are never queued. - Free keys per network: 3 per month — verify an email for more hours per key. Need bigger files, faster turnaround, or volume discounts? We run committed- volume plans with custom transcription pipelines — contact support@soundsgood.one. ## Data & reliability Audio and transcripts are never stored: both exist only for the duration of the request that carries them, and are gone once the response completes. Models are never trained on customer audio or transcripts. The only record kept is account-linked billing metadata — audio-seconds processed, for billing — never audio or transcript content. Every bill is auditable per-request: each response carries `audio_duration_sec`, `billed_speech_sec`, and `speech_ratio`, and exactly the reported `billed_speech_sec` is drawn from your balance (see Pricing). The service is pre-launch: we target 99.9% uptime, and there is no contractual SLA yet. Committed-volume plans are negotiated to fit: volume discounts, raised or removed concurrency and file-length caps, faster turnaround, and an SLA with service credits — contact support@soundsgood.one. Security contact: support@soundsgood.one. We don't have a standing data-processing agreement yet, and we would rather say so than imply otherwise. If you need one to move forward, write to support@soundsgood.one: asking is what schedules it, and you will get a straight answer about where it stands rather than a maybe. Working under a compliance regime? Until our SOC 2 / ISO 27001 attestation lands (on the roadmap), a common pattern is two tracks: an attested vendor for the production audio your regime covers, and Verbi for everything it doesn't — development, testing, internal QA, evaluation and experimentation — where iteration speed and price dominate. What counts as regulated is your call; the same API surface (glossary, diarization, captions, word timestamps) keeps transcripts comparable across both tracks, and your audio and transcripts are never stored either way. ## Languages 25 European languages, detected automatically per file — no language parameter needed: Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Slovak, Slovenian, Spanish, Swedish, Russian, Ukrainian. Non-Latin scripts come back in their own script (Russian in Cyrillic). The response reports which of these languages the transcript actually came out in (`language` + `language_confidence`, detected from the output text), so you can verify a file was transcribed in the language you expected rather than a misdetection. If a file mixes languages, `language_segments` maps which part of the audio came out in which language (see Response shape). ## Endpoints - `GET /health` — liveness + capacity. - `GET /v1/pricing` — public price list, limits, free-tier policy (no auth). - `POST /v1/signup` — mint an api key; optional JSON body `{"email": "..."}` (triggers the verification mail; the email bonus applies once verified). - `GET /v1/verify?token=...` — the one-time link from the verification mail; activates the email grants (no auth — the token is the credential). - `POST /v1/verify/resend` — re-send the verification mail with a fresh link (auth; the previous link stops working; at most once per hour). - `GET /v1/billing` — your account: plan, `email_verified`, balances, remaining hours (auth). - `POST /v1/transcribe` — body is the raw audio bytes in any common format (wav, mp3, m4a, opus, ogg, flac, webm — anything ffmpeg decodes). Video containers work too (mp4, webm, mkv, mov): the audio track is extracted and transcribed; you never need to demux first. Returns transcript JSON with per-word speaker labels + a `speakers` summary and the billed-speech audit fields. Query parameters: `?diarize=false` skips diarization (~15% faster — see Speed); `?captions=srt`, `?captions=vtt`, or `?captions=srt,vtt` adds a `captions` response object with the requested formats rendered from the word timestamps — request every format you need in one transcription; `?vad_sensitivity=complete|economy` tunes the speech/billing trim (see Pricing); `?partials=true` streams incremental partial transcripts (see below). Glossary (custom vocabulary): send an `X-Glossary` header — a JSON array of up to 100 domain terms (proper names, jargon, products, tickers) — to have them recognized with better fidelity. It's free. Terms may be multi-word phrases and non-ASCII; every term is boosted at the same fixed strength. Example: `-H 'X-Glossary: ["Monro","SunTrust","paracetamol"]'`. Use it for RARE terms only — names, brands, jargon the model would not know. Do not add common dictionary words: the recognizer already knows them (zero recall gain), and boosting a common word can hijack acoustically-similar rare terms nearby (measured: boosting a common medical word overwrote the correct brand name it resembles). Validation is strict and up-front: a term the recognizer's tokenizer can't represent rejects the whole request with `400 INVALID_GLOSSARY`, naming the offending terms in `invalid_terms` — BEFORE any processing or billing, so fixing the glossary and retrying costs nothing. An accepted request applied every term (`glossary_applied` echoes the count). The glossary is per-request and stateless. Channels and tracks: all channels of the audio stream are downmixed to mono before transcription — speakers are identified by voice (diarization), not by channel, so a stereo call recording with one person per channel still comes back speaker-labelled, but not via the channel split. If a container carries several separate audio TRACKS (e.g. a multi-language video or a multitrack recording), only the default track is transcribed — to transcribe another track, extract it first (`ffmpeg -i in.mkv -map 0:a:1 track2.m4a`) and send that. Billing is on the mono downmix's detected speech — a stereo file never bills double. - `POST /v1/contact` — body `{"message": "...", "kind": "feedback|bug|question", "reply_to": "..."}` (auth; `message` required, ≤5000 chars): files feedback that reaches a human operator. Your agent hit a problem or has a feature request? It can file it directly. - `POST /v1/billing/topup` — body `{"hours": N}` (whole hours, min 20): returns a hosted Stripe Checkout URL at $0.05/speech-hour; the hours are credited when the payment completes. ## Response shape A successful `POST /v1/transcribe` returns one JSON object: - `text` — the full transcript. - `words` — `[{word, start, end, confidence, speaker}]`; `start`/`end` are seconds in the original audio; `speaker` (e.g. `"SPEAKER_0"`) present when diarization ran. - `language`, `language_confidence` — the dominant language of the returned transcript as an ISO 639-1 code (e.g. `"en"`, `"ru"`) plus a confidence in `[0,1]`, auto-detected from the transcript TEXT. Lets you verify the audio came out in the language you expected (a misdetected clip that transcribes in the wrong script reports that wrong language — that's the point). One of the 25 supported languages. Both fields are omitted together when the transcript is too short to classify (a handful of words). This is the language of the OUTPUT, not a separate audio-language probe. - `language_segments` — present ONLY when the transcript mixes languages (two or more detected): `[{start, end, language, confidence}]`, sentence- level spans of the transcript merged into runs of one language, times in seconds in the original audio. A single-language transcript (the common case) omits this field — check `language` alone. Granularity is sentence spans by design: single words are language-ambiguous, so word-by-word language is deliberately not offered. - `speaker_count`, `speakers` — `[{speaker, word_count, total_speech_sec}]`. - `audio_duration_sec`, `billed_speech_sec`, `speech_ratio` — the billing audit trio (see Pricing). - `vad_sensitivity` — the level this request actually ran with. - `remaining_hours` — balance after this request was billed. - `n_chunks`, `request_id`. - `timing` — server-side `processing_sec`, realtime factor `rtfx`, and related diagnostics. - `captions` — only when `?captions=` was sent: an object keyed by format (`{"srt": "...", "vtt": "..."}`), each value the complete caption file as a string. Cues break on sentence ends, speaker changes, long pauses, and length; diarized cues are prefixed `SPEAKER_N:`. - `glossary_applied` — only when an `X-Glossary` header was sent: the count of glossary terms boosted (a request with an unrepresentable term was already rejected with 400 INVALID_GLOSSARY before processing, so an accepted request applied every term). The response keep-alive-streams whitespace while a long file decodes, so a 60-minute file finishes past proxy idle timeouts; the body is still a single JSON object (leading whitespace is ignorable). Opt-in partial transcripts: `POST /v1/transcribe?partials=true` (off by default) switches the 200 response to NDJSON (`application/x-ndjson`), one JSON object per line (skip blank keep-alive lines): zero or more `{"type":"partial","n_chunks_done":N,"text":""}` preview frames as chunks decode, then exactly one terminal frame — the normal response object with `"type":"final"` added (or `{"type":"error",...}` on a processing failure). Partials are strictly in order and cumulative: each frame contains the transcript of everything decoded so far from the start of the audio, so every partial supersedes the previous one (`n_chunks_done` only ever increases). The complete final transcript always arrives at the end; partials are only previews before it. Partials are text-only and speaker-unstable — speaker labels are assigned at the final clustering pass, so only the final frame carries the authoritative speaker-labelled transcript. Request-envelope errors keep their normal status codes and plain JSON bodies; verdicts about the audio itself arrive inside the stream — see Errors below. Billing on the partials stream follows delivery: disconnect before the final frame and you are billed for exactly the speech whose transcript already reached you in partial frames (a processing failure on our side still bills nothing). The default non-partials path is unchanged: nothing is billed unless you receive the final object. ## Errors The error surface is categorical: whether an error is an HTTP status or an in-stream object depends on WHAT failed, never on timing. Envelope errors — the request itself was rejected, nothing was processed — are JSON `{"error_code", "error_message"}` with an HTTP status: - 401 UNAUTHORIZED — missing or invalid Bearer key. - 403 ACCOUNT_SUSPENDED — the account has been suspended (contact us). - 400 INVALID_SENSITIVITY — unparseable `vad_sensitivity` value. - 400 INVALID_CAPTIONS_FORMAT — `captions` value other than srt/vtt (or a comma-list containing one). - 400 INVALID_GLOSSARY — `X-Glossary` header not a JSON array of terms, a term empty/over-long, more than 100 terms, or a term the recognizer's tokenizer cannot represent (the body then lists them in `invalid_terms`). Always rejected before any processing or billing — fix and retry for free. - 400 INVALID_MESSAGE — contact message missing or over 5000 characters. - 400 INVALID_TOPUP_HOURS — top-up hours not a positive whole number, or below the 20-hour minimum. - 400 NO_EMAIL — verification resend on a key without an email address. - 400 ALREADY_VERIFIED — verification resend after the address verified. - 402 QUOTA_EXCEEDED — balance already exhausted (includes a `payment` block). - 429 TOO_MANY_CONCURRENT — concurrency limit hit (includes `retry_after_sec`). - 429 SIGNUP_LIMIT — this network reached the free-key signup cap. - 429 RESEND_RATE_LIMITED — verification mail re-sent recently (includes `retry_after_sec`). - 404 INVALID_TOKEN — unknown or already-used email-verification token. - 502 PAYMENT_UPSTREAM — the payment provider request failed; retry shortly. - 502 MAIL_UPSTREAM — the verification mail could not be sent; retry shortly. - 503 SERVICE_UNAVAILABLE — the customer store is unreachable; retry shortly. Any endpoint that touches your account can return it; it is always fast (bounded store timeouts), so retrying with backoff is safe. Content verdicts — your audio was accepted (200, stream already open) but a problem was found while processing it — arrive as the TERMINAL JSON object of the stream, same fields as an HTTP error body. Rule: if the final object carries `error_code`, the request failed: - EMPTY_AUDIO — no decodable audio in the request body. - FILE_TOO_LONG — file exceeds the 60-minute single-call cap. - QUOTA_EXCEEDED — the clip's detected speech exceeds the remaining balance (includes a `payment` block; nothing billed). - PROCESSING_FAILED — decode or metering failure mid-stream. ## Recipes **Transcribe and wait correctly** — stream the response; never set a total timeout shorter than `audio_duration / 10 + 30 s`: import requests with open("episode.mp3", "rb") as f: r = requests.post("https://verbi.services/v1/transcribe", headers={"Authorization": f"Bearer {KEY}"}, data=f, timeout=(10, 120)) # (connect, between-bytes) out = r.json() # leading whitespace is fine if "error_code" in out: raise RuntimeError(out["error_code"]) print(out["text"], out["billed_speech_sec"]) **Live progress on long files** — read NDJSON lines, keep the last frame: with requests.post(url + "?partials=true", headers=auth, data=f, stream=True, timeout=(10, 120)) as r: final = None for line in r.iter_lines(): if not line.strip(): continue # keep-alive frame = json.loads(line) if frame["type"] == "partial": print(frame["text"][-80:]) else: final = frame # "final" or "error" **Estimate cost before sending** — worst case `audio_duration_sec / 3600` speech-hours (speech_ratio ≤ 1.0); check `GET /v1/billing` for `remaining_hours` first. Silence is free: a 60-min file that is half silence bills ~0.5 h. **Handle busy/unavailable** — 429: wait `retry_after_sec` and retry. 503 and 502: retry with exponential backoff (both are always fast rejections, safe to retry). A dropped connection mid-stream on the default (non-partials) path: simply retry the whole request — nothing was billed unless you received the final object. ## More - OpenAPI: https://verbi.services/api/openapi.yaml - Contact: support@soundsgood.one