# Verbi is a speech-to-text API It listens to a recording or a live feed and hands back words your code can use: who said what, when, and how surely. - Send a file. One HTTP call, one complete transcript back. - Let Verbi listen live. Over a WebSocket, each utterance arrives about a second after the pause that ends it. **Verbi bills only for the speech it hears. Silence is free.** It also tells speakers apart, times every word with confidence, hears music — all of that in 25 European languages — naturally, by default. Ask, and Verbi writes SRT/VTT captions, spells your rare names right from a glossary, and reports which everyday sounds the recording contained — a knock, a cough, typing. Free tier: 2 speech-h/mo, or 10 speech-h/mo + a one-time 20 h welcome with a verified email. After that: flat $0.10 USD per speech-hour, prepaid — $1 minimum (10 hours). Getting started: nothing to set up on your side — no GPU, no model downloads, no scaling, no on-call. Integrate using the recipes below, or, easier still, ask your agent. Limits: - 2 transcriptions at a time without a verified email, 10 with one, 25 once paid. - Verbi reads no faster than it transcribes: a faster upload simply waits. What Verbi does not do, so you find out now rather than in week three: no word-by-word realtime ASR (the unit is a complete utterance, about a second behind the speaker); no lyrics — sung vocals are kept out of the transcript and the bill, and under mode=vad_diar_off, which transcribes everything, lyrics come out poorly; no SOC 2 or standing DPA yet; speaker labels are measured in-house, not independently audited. That's all there is to it. This file is the whole product: recipes first, for people; then the compact reference, for agents. And there's no retention policy to read, because **nothing is ever retained** — not the audio, not the transcript, not a voiceprint — less to worry about, and part of why the whole product fits on one page. Write to contact@verbi.services — or POST /v1/contact from a script — with anything: feedback, a bug, a gap in this file, a feature you want. And if a limit, the throughput or the price is in your way, say so: committed-volume plans raise or remove limits and come with volume discounts, and complex custom integrations are possible too. Terms https://verbi.services/terms · Privacy https://verbi.services/privacy · OpenAPI https://verbi.services/api/openapi.yaml ## Recipes ### Common options They work with every recipe — query parameters on the URL (the socket URL too), and one header, which on a socket goes in the start message instead: X-Glossary: ["SunTrust","paracetamol","Kühne + Nagel"] (socket: "glossary" in the start message) up to 100 rare terms, spelled the way you want them back, free. BOOST RARE TERMS ONLY: names, brands, jargon — an ordinary word can hijack a similar-sounding rare one. A bad term rejects the request before anything is billed. ?track=1 a different audio track of a multi-track video (0-based; default: the container's default track) ?channel=left one side of a stereo call instead of the downmix (left, right, or 0-based indices; several = a submix); send the file once per side and add mode=diar_off when the channel already says who spoke. Speakers are told apart by voice by default, never by channel ?captions=srt,vtt ready-to-publish caption files rendered from the word timestamps, free; the response carries them as {"captions": {"srt": ..., "vtt": ...}} ?max_billed_speech_sec=600 stop this request at the last whole utterance inside the budget; the response says so ("capped": true) ?cost_center=site_17 tag the request; GET /v1/usage?since=&until= then reports what each label was billed (a label is an opaque id, never a name or email) ?mode=diar_off words only, no speakers · ?mode=vad_diar_off everything decoded, full duration billed ?events=music,knock speech events always, plus only these other kinds (music, vocals and the sounds below; absent: every kind, the sounds included; events=speech_only: speech alone) — events_settled_upto then waits on those kinds alone (music ~1.2 s behind the audio, vocals ~4 s, a sound about a second plus its minimum length); the bill is the same whatever is reported Sounds, reported by default (events=speech_only, or a list without them, turns them off): dog (barking, howling, whimpering), cat (meowing, purring), cough (coughs and sneezes), snoring, typing (a keyboard, a typewriter), water (a tap, a sink, a bath, pouring, a toilet flush), appliance (a hair dryer, a vacuum cleaner, an electric shaver or toothbrush), knock, shouting (screams, shouts, yells, cheering), laughter, rain, bird (birdsong and calls, wings), dishes (dishes, pots and pans, cutlery). Each comes back as events like music's: an event opens about a second after the sound starts (dog about a second and a half; rain about 3.6 s, at times up to about 6 s) and closes about a second after it stops (dog and laughter about two seconds, so a pause between barks or laughs does not split one event); dog, rain, bird and dishes are calibrated for clean audio only (less reliable on telephone- or radio-quality audio). They say what the recording contained: detected from the audio signal only, indicative, not a substitute for human verification — a quiet or brief sound may be missed, and a sound can read as music too. ### API key Every recipe reads it from VERBI_KEY. Mint one once — no email, no card, the free tier starts immediately: VERBI_KEY=$(curl -s -XPOST "https://verbi.services/v1/signup" | python3 -c 'import sys,json;print(json.load(sys.stdin)["api_key"])') && export VERBI_KEY ### Transcribe a file Use cases: turn an interview or podcast into searchable text · give an agent word timestamps for quotations · transcribe a video as it is. Send the bytes, get one JSON object back: text, words with timestamps and confidence, speaker labels, music spans, billed seconds. Up to 60 minutes a call; longer files go through the live client below, which streams a file too. import os, requests KEY = os.environ["VERBI_KEY"] # your api key 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"]) #### Example single-response output The first 90 seconds of this NASA film, as one JSON object: https://images.nasa.gov/details/NHQ_2019_1219_How%20We%20Are%20Going%20to%20the%20Moon%20-%204K Abridged — most words and the token elided. The same 90 seconds streamed are under the live recipe below, laid out the same way. { "text": "Mm-hmm. Between 1968 and 1972, America launched nine human missions to the moon, six of which successfully touched down, allowing 12 men to walk on the lunar surface. ...", "words": [{"word": "Mm-hmm.", "start": 0.18, "end": 0.725, "confidence": 0.0, "speaker": "Efov Ekok"}, {"word": "Between", "start": 1.05, "end": 1.61, "confidence": 0.8776, "speaker": "Efov Ekok"}, ...], "audio_events": [{"id": 1, "kind": "speech", "start": 0.18, "end": 37.275, "text": "..."}, {"id": 2, "kind": "music", "start": 4.78, "end": 89.98}, {"id": 3, "kind": "speech", "start": 38.47, "end": 89.995, "text": "..."}], "speaker_count": 1, "speakers": [{"speaker": "Efov Ekok", "word_count": 246, "total_speech_sec": 88.3, "keys": ["Efov Ekok"]}], "diar_state": "...", "audio_duration_sec": 90.0, "billed_speech_sec": 88.295, "speech_ratio": 0.981, "speech_segment_count": 2, "language": "en", "mode": "default", "engine": "verbi-2026-09-11.4", "request_id": "51f5605-44", ... } ### Transcribe a live source Use cases: follow a broadcast, a radio channel or an RTSP/RTMP feed as it airs · feed a listening agent complete utterances from a microphone · give a robot hearing — voice commands, keyword triggers, reactions to speech or music starting · show the wearer of glasses or a badge, all day, what is said around them and what else is sounding. Use ffmpeg to feed a microphone, stream or recording to this client as 16 kHz mono PCM (the commands above the code). It prints utterances as they arrive, carries speaker state across reconnects, and keeps the last three minutes it sent to resume from the last transcribed second: as long as that history still reaches the resume point, a cut costs at most a clipped word at the seam and a little re-billed lead-in; a longer stall loses what aged out. Add capture_offset_sec to a frame's timestamps to place them on the capture timeline. Each capture holds one slot: an email-verified key (concurrency 10) carries ten, a paid one 25. Buffering, replay and budget rules: RECIPE NOTES and WS below. On a device, a microcontroller (in glasses or a badge) can be the client itself, over the same socket. Send the 44-byte WAV header with both sizes 0xFFFFFFFF, then 16 kHz mono PCM16 in frames of about 100 ms, or Opus at 24 kbit/s where the device can encode it: a tenth of the bytes to send and to hold. Keep sending through quiet: silence is not billed, and 30 s without audio closes the socket. Let a blocked write wait: Verbi can stop reading for about 2 min when it is behind, and a socket library that gives up after a few seconds, common on small devices, drops the connection and loses what was accepted but not yet delivered. Keep capturing into a buffer meanwhile, on a task the display does not wait on. Under mode=diar_off no speaker frames are sent; with speakers on, accept text frames of about 130 KB (a diar_state token grows to 128 KB). #### Stream # ffmpeg -v error -i -f s16le -ar 16000 -ac 1 - \ # | python3 live.py > transcript.ndjson (pip install websockets) # ffmpeg -v error -i recording.mp3 -f s16le -ar 16000 -ac 1 - | python3 live.py # a file, no length cap import asyncio, collections, json, os, sys, websockets KEY = os.environ["VERBI_KEY"] # your api key URL = "wss://verbi.services/v1/transcribe" # ?mode=diar_off, ?cost_center=..., WAV = (b"RIFF\xff\xff\xff\xffWAVEfmt \x10\x00\x00\x00" # ?max_billed_speech_sec=... go here b"\x01\x00\x01\x00\x80>\x00\x00\x00}\x00\x00\x02\x00\x10\x00" b"data\xff\xff\xff\xff") # 16 kHz mono PCM16, open-ended BPS, KEEP, EOF = 32000, 180, object() # bytes per second; seconds of sent audio kept async def main(): audio = asyncio.Queue(1000) # read from stdin, not yet sent: ~2 min, async def read_stdin(): # then the reader waits, the source backs up odd = b"" while chunk := await asyncio.to_thread(sys.stdin.buffer.read1, 4096): chunk, odd = odd + chunk, b"" if len(chunk) % 2: chunk, odd = chunk[:-1], chunk[-1:] # whole samples only await audio.put(chunk) await audio.put(EOF) asyncio.ensure_future(read_stdin()) state, base = None, 0.0 # newest diar_state; this request's start in the capture pending, sent = collections.deque(), collections.deque() # to send first; sent on this request while True: # one connection per attempt got, ended, dropped = 0.0, False, 0 # last transcribed second; end sent; bytes aged out try: async with websockets.connect(URL) as ws: await ws.send(json.dumps({"type": "start", "api_key": KEY, "diar_state": state})) await ws.send(WAV) # every connection is a new request async def pump(): # pending, then the queue, as binary frames nonlocal ended, dropped while True: chunk = pending.popleft() if pending else await audio.get() if chunk is EOF: # source ended: say so, then wait ended = True; await ws.send(json.dumps({"type": "end"})); return sent.append(chunk) # into history BEFORE it goes out: a failed await ws.send(chunk) # send is resent from there while sum(map(len, sent)) > KEEP * BPS: dropped += len(sent.popleft()) sender = asyncio.ensure_future(pump()) # send and read at the same time try: async for msg in ws: # frames back, one JSON object each frame = json.loads(msg) if frame["type"] == "diar_state": state = frame["diar_state"] for seg in frame.get("segments", []): got = max(got, seg["end"]) frame["capture_offset_sec"] = round(base, 3) # add to this frame's times print(json.dumps(frame), flush=True) if frame["type"] == "error": sys.exit(frame["error_code"]) if frame["type"] == "final": return finally: sender.cancel() why = "a clean close without a final frame" # the loop ended on its own except websockets.ConnectionClosed as e: why = f"close {e.rcvd and e.rcvd.code}" except OSError as e: # no route, refused: keep trying why = str(e) buf = b"".join(sent); sent.clear() # resend from the last transcribed second skip = min(len(buf), max(0, int(got * BPS) // 2 * 2 - dropped)) tail = [buf[i:i + 65536] for i in range(skip, len(buf), 65536)] if ended and not tail: # everything was delivered before the close print(f"# {why} after the end of audio; nothing left to resend", file=sys.stderr); return pending.extendleft(reversed(tail)) if ended and EOF not in pending: pending.append(EOF) base += (dropped + skip) / BPS print(f"# reconnecting after {why}, resuming at {base:.1f} s", file=sys.stderr) await asyncio.sleep(1) asyncio.run(main()) #### Browser From a web page the same socket takes the microphone directly: const ws = new WebSocket("wss://verbi.services/v1/transcribe"); const show = (f) => { for (const s of f.segments ?? []) console.log(s.text); }; // some frames carry none ws.onmessage = (ev) => { const f = JSON.parse(ev.data); if (f.type === "transcript" || f.type === "final") show(f); // the final frame can carry the last words if (f.type === "error") console.error(f.error_code, f.error_message); }; ws.onopen = async () => { ws.send(JSON.stringify({type: "start", api_key: API_KEY})); const mic = await navigator.mediaDevices.getUserMedia({audio: true}); const rec = new MediaRecorder(mic, {mimeType: "audio/webm;codecs=opus", audioBitsPerSecond: 24000}); rec.ondataavailable = (ev) => { if (ev.data.size) ws.send(ev.data); }; // a Blob goes out as one frame, in order rec.onstop = () => { mic.getTracks().forEach((t) => t.stop()); // fires after the last chunk ws.send(JSON.stringify({type: "end"})); }; // so end never overtakes audio rec.start(100); // one chunk every 100 ms stopButton.onclick = () => rec.stop(); }; #### Example streaming output The same 90 seconds as the single-response example above, streamed at real-time pace: the whole exchange, → you, ← Verbi, abridged the same way. What was one object is now frames as they happen; the narration is speech, the score under it one music event from start to end, unbilled. → {"type": "start", "api_key": "vb_sk_..."} (diar_state, glossary, correlation_id, events may ride here too) → ← {"type": "event", "id": 1, "kind": "speech", "edge": "open", "t": 0.18, "events_settled_upto": 0.0} ← {"type": "transcript", "segments_delivered": 1, "billed_speech_sec": 0.545, "billed_speech_sec_total": 0.545, "segments": [{"event_id": 1, "start": 0.18, "end": 0.725, "text": "Mm-hmm.", "words": [...], "billed_speech_sec": 0.545}], ...} ← {"type": "event", "id": 2, "kind": "music", "edge": "open", "t": 4.78, "events_settled_upto": 1.05} ← {"type": "transcript", "segments_delivered": 2, "billed_speech_sec": 16.145, "billed_speech_sec_total": 16.69, "events_settled_upto": 17.18, "segments": [{"event_id": 1, "start": 1.05, "end": 17.195, "language": "en", "text": "Between 1968 and 1972, America launched nine human missions to the moon, six of which successfully touched down, allowing 12 men to walk on the lunar surface. NASA's next chapter of lunar exploration, called Artemis,", "words": [{"word": "Between", "start": 1.05, "end": 1.61, "confidence": 0.8638}, {"word": "1968", "start": 1.61, "end": 2.65, "confidence": 0.9854}, ...]}]} ← {"type": "stats", "window_sec": 60, "wait_p50_sec": 0.016, "delta_latency_p50_sec": 0.277, ...} ← ... (transcript, event and stats frames as the narration goes on) ← {"type": "event", "id": 1, "kind": "speech", "edge": "close", "t": 37.275, "duration_sec": 37.095, "duration_sec_total": 37.095, "events_settled_upto": 38.4} ← {"type": "event", "id": 3, "kind": "speech", "edge": "open", "t": 38.47, "events_settled_upto": 38.4} ← {"type": "diar_state", "diar_state": "...", "pass": 1, "speakers": [{"name": "Nuro Ufor", "keys": ["Nuro Ufor"]}], ...} ← {"type": "transcript", "segments_delivered": 5, "billed_speech_sec_total": 52.805, "speaker_turns": [[0.18, 0.725, "Nuro Ufor"], [1.05, 37.28, "Nuro Ufor"], [38.47, 51.97, "Nuro Ufor"]], ...} → {"type": "end"} (the source ended; keep reading until final) ← {"type": "event", "id": 2, "kind": "music", "edge": "close", "t": 89.98, "duration_sec": 85.2, "duration_sec_total": 85.2, "events_settled_upto": 71.9} ← {"type": "transcript", "segments_delivered": 7, "billed_speech_sec_total": 88.295, "segments": [{"event_id": 3, "start": 71.9, "end": 89.995, "text": "An exploration upper stage, a massive co...", "words": [...]}], ...} ← {"type": "event", "id": 3, "kind": "speech", "edge": "close", "t": 89.995, "duration_sec": 51.525, "duration_sec_total": 88.62, "events_settled_upto": 90.0} ← {"type": "final", "speaker_count": 1, "speakers": [{"speaker": "Nuro Ufor", "total_speech_sec": 85.78, "keys": ["Nuro Ufor"]}], "audio_duration_sec": 90.0, "billed_speech_sec": 0.0, "billed_speech_sec_total": 88.295, "speech_ratio": 0.981, "speech_segment_count": 2, "language": "en", "mode": "default", "engine": "verbi-2026-09-11.4", "request_id": "51f5605-45", ...} ### Transcribe a folder in parallel Use cases: process an interview collection or a media archive · empty a drop folder that a recorder fills, on a schedule · index a day's call recordings for search overnight. Transcribe files of any length into adjacent `.ndjson` files, using the account's concurrency limit. Completed files are skipped; partial results need review because replay can bill delivered speech again. Exit 0 means all files completed and no partial remains. Common options apply per file. Other captures share the account's slots; a busy refusal stays partial. Retry and accounting rules: ERRORS and LIMITS. # python3 folder.py (pip install websockets requests) import asyncio, json, os, pathlib, requests, sys, websockets KEY = os.environ["VERBI_KEY"] # your api key URL = "wss://verbi.services/v1/transcribe" # + ?mode=..., ?cost_center=..., ... EXTS = {".wav", ".mp3", ".m4a", ".opus", ".ogg", ".flac", ".webm", ".mp4", ".mkv", ".mov"} async def transcribe(path): # -> "OK" | error_code | "INTERRUPTED" partial = path.with_name(path.name + ".partial.ndjson") final = None try: async with websockets.connect(URL) as ws: await ws.send(json.dumps({"type": "start", "api_key": KEY})) async def pump(): # the file in, as binary frames with path.open("rb") as f: while chunk := f.read(1 << 16): await ws.send(chunk) await ws.send(json.dumps({"type": "end"})) sender = asyncio.ensure_future(pump()) try: with partial.open("w") as out: # every frame saved as it lands async for msg in ws: out.write(msg + "\n") frame = json.loads(msg) if frame["type"] in ("final", "error"): final = frame; break finally: sender.cancel() except websockets.ConnectionClosed: pass # no final: interrupted if final is None: return "INTERRUPTED" # partial kept for review if final["type"] == "error": return final["error_code"] if final.get("capped"): return "CAPPED" partial.replace(path.with_name(path.name + ".ndjson")) return "OK" async def main(folder): if not pathlib.Path(folder).is_dir(): sys.exit(f"{folder}: not a folder") files = [p for p in sorted(pathlib.Path(folder).rglob("*")) if p.is_file() and p.suffix.lower() in EXTS] parked = [p for p in files if p.with_name(p.name + ".partial.ndjson").exists()] todo = [p for p in files if p not in parked and not p.with_name(p.name + ".ndjson").exists()] N = requests.get("https://verbi.services/v1/billing", timeout=30, headers={"Authorization": f"Bearer {KEY}"}).json()["concurrency_limit"] results = {} async def worker(): while todo: path = todo.pop() results[path] = await transcribe(path) print(f"{path.name}: {results[path]}", file=sys.stderr) if results[path] in ("QUOTA_EXCEEDED", "UNAUTHORIZED"): todo.clear() # stop the run await asyncio.gather(*(worker() for _ in range(N))) for p in parked: print(f"{p.name}: PARTIAL from an earlier run — look, then delete it to retry", file=sys.stderr) if not results and not parked: print("nothing to do", file=sys.stderr) sys.exit(0 if not parked and all(r == "OK" for r in results.values()) else 1) if __name__ == "__main__": asyncio.run(main(sys.argv[1])) ### Keep your own speaker registry Use cases: recognize recurring podcast guests or meeting participants · keep a roster of familiar names on your side · version, test and reset speaker evidence in a tool you own. Every diarized response carries `diar_state`: a token with each speaker's keys in the clear and their voice evidence sealed. Send it back as `X-Verbi-Diar-State` (on a socket: `"diar_state"` in the start message; the newest token comes back as `diar_state` frames, which the live client already carries) and returning voices keep their keys; Verbi stores none of it. Enroll each person once, from that voice alone, on the kind of microphone you will hear them on; an ordinary run presents the registry and drops the returned token, so strangers never accumulate. Names are your labels for likely matches, not identities. # python3 registry.py enroll alice alice-alone.wav # once per person # python3 registry.py run meeting.mp3 # on every recording import json, os, pathlib, requests, sys KEY = os.environ["VERBI_KEY"] # your api key URL = "https://verbi.services/v1/transcribe" STORE = pathlib.Path("registry.json") # YOUR store: token + your names def load(): if not STORE.exists(): return {"token": None, "names": {}} return json.loads(STORE.read_text()) def send(path, token): hdrs = {"Authorization": f"Bearer {KEY}"} if token: hdrs["X-Verbi-Diar-State"] = token # present the registry with open(path, "rb") as f: r = requests.post(URL, headers=hdrs, data=f, timeout=(10, 120)) out = r.json() if "error_code" in out: sys.exit(f"{out['error_code']}: {out['error_message']}") if out.get("diar_state_reused") == "none": # newer model: voices enrolled sys.exit("registry not usable — enroll again") # afresh under new keys return out def enroll(name, clip): reg = load() out = send(clip, reg["token"]) # each clip presents the token if len(out["speakers"]) != 1: # the previous one returned sys.exit(f"{clip}: want one voice, found {len(out['speakers'])}") keys = out["speakers"][0]["keys"] if {reg["names"][k] for k in keys if k in reg["names"]} - {name}: sys.exit("this voice already matches another name — keep the old registry") for k in keys: reg["names"][k] = name reg["token"] = out["diar_state"] # everyone enrolled so far STORE.write_text(json.dumps(reg)) def run(path): reg = load() out = send(path, reg["token"]) # its returned token is dropped who = {} # every key of a speaker -> name for spk in out["speakers"]: mine = sorted({reg["names"][k] for k in spk["keys"] if k in reg["names"]}) for k in spk["keys"]: # two names = one voice, or an who[k] = " or ".join(mine) or f"unknown ({spk['speaker']})" # ambiguity line, last = [], None for w in out["words"]: # fold words into speaker turns name = who.get(w["speaker"], w["speaker"]) if last is not None and name != last: print(f"{last}: {' '.join(line)}"); line = [] last = name; line.append(w["word"]) if line: print(f"{last}: {' '.join(line)}") if __name__ == "__main__": if sys.argv[1:2] == ["enroll"]: enroll(sys.argv[2], sys.argv[3]) elif sys.argv[1:2] == ["run"]: run(sys.argv[2]) else: sys.exit("usage: registry.py enroll | run ") ## Reference (compact, for agents) Human-readable: https://verbi.services/redoc BASE https://verbi.services · everything synchronous: answer comes back on same connection that asked, no jobs, no polling · single-response form = POST /v1/transcribe → one JSON object · stream form = WebSocket on same path (see WS) AUTH every authenticated call carries header Authorization: Bearer BILLING hours drawn in fixed order: monthly allowance → welcome grant → paid credit · monthly hours reset every 30 days on account's own cycle, unused ones expire · welcome grant never expires · allowance, credit and concurrency belong to account, not to a key: extra keys add no hours · used_this_period_hours counts free allowance only · paid credit prepaid in whole hours via POST /v1/billing/topup, minimum 10 hours = $1.00 · payment via hosted Stripe Checkout page (no card data passes through Verbi); hours credited when checkout completes MODES ?mode= chooses what runs · ?mode=default = speech meter + diarization · diar_off = speech meter, no speakers · vad_diar_off = no speech detection, no speakers: audio cut into fixed chunks of ~15 s, each cut at quietest nearby point, everything decoded, singing included; model not built for lyrics, transcribes them poorly; full duration billed; up to 3× faster on dense speech; meant for callers who run their own VAD · response reports mode that ran METER billed = detected speech that comes back as words, each segment in full · segment with no words (a cough, a laugh) bills 0 and is not sent on stream, though its speech event still opens and closes · under vad_diar_off full duration billed; stretch with no words not sent there either: its seconds ride a transcript frame's billed_speech_sec, so such frame may have no segments · every result carries audio_duration_sec, billed_speech_sec, speech_ratio; ratio ≤1 · exactly billed_speech_sec drawn from balance · meter fails open: audio dropped from bill only when confidently music without speech · sung vocals count as music: in default and diar_off kept out of transcript and bill · rap, speech-rhythm vocals, dialogue over music kept · instrumental music bills at most 8% of its runtime · on film mixes billed speech lands within 0.85–1.05× of subtitle-timed human reference · under vad_diar_off no meter: singing transcribed and billed like everything else, lyrics come out poorly DELIVERY delivery is billing · single response charged only when final object delivered: cut or failed request free, so retry it · stream frames billed as delivered · on disconnect or in-stream error, what was delivered stays billed, rest not billed · service failure bills nothing further · each transcript segment carries its own billed_speech_sec · each transcript frame carries billed_speech_sec (what that frame billed) and billed_speech_sec_total (request's running total including that frame) · cut stream has no terminal frame → last billed_speech_sec_total seen = bill · budgets always billed seconds, never wall time SPEED file finishes in ≤ audio_duration/10 plus a few seconds · set deadline ≥ that plus 30 s; first request after hours idle ~27 s slower · first byte < 1 s, then keep-alive whitespace ~every 5 s until object · read timeout ≥ 60 s between bytes, never as total · write timeout tens of seconds: uploads accepted at decode pace, paused write = backpressure; shared socket has one timeout · utterance ends after ~0.2 s of silence; its transcript arrives p50 0.7–0.8 s after that · continuous talk split at quiet points every 3–8 s, forced ~16 s in, never past ~19 s · delivered text final, never fragments · fast uploads decode in bursts; live sources decode at speaking pace · same audio on same engine may differ in < 1% of words (casing, punctuation, digits); cache result if you need byte-identical output MODEL modified NVIDIA Parakeet-TDT 0.6B v3 (https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3) · published benchmarks score base model, not your audio → measure on free tier LANGUAGES 25, auto-detected per file, no parameter to set: 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 · text comes back in each language's native script · evaluation deepest in English · language fields classify output text LIMITS single-response call capped at 60 minutes of audio; stream no cap · ?max_billed_speech_sec= bounds billed speech of one request (e.g. 10 h of audio at 7200 bills at most 2 speech-hours) · request stops at last whole utterance under ceiling, bills that prefix, decodes nothing past it · response or terminal frame carries max_billed_speech_sec and capped; capped_reason="max_billed_speech_sec" only when ceiling was hit; when capped, audio_duration_sec = audio metered up to stop · bad syntax → 400 INVALID_MAX_BILLED_SPEECH · across reconnects keep billed_speech_sec_total from frames, send remainder as next ceiling · simultaneous requests need disjoint reservations · Concurrency 2 in-flight transcriptions for anonymous accounts, 10 for email-verified, 25 once the account has paid · limit per account, shared by its keys; strongest rung earned applies: paying earns 25 without email, verifying email later adds hours only · request over cap → immediate 429 with retry_after_sec, never queued · dead request frees its slot within ~2 min; standing capture holds one slot · /v1/billing reports concurrency_limit · contracted accounts differ · signup cap = 3 new accounts per 30 days per network (office, campus or VPN egress shares one), anonymous and email signups together; verifying email or minting keys is not a signup · diar_state key = 1–64 UTF-8 bytes; token holds ≤16 keys per entry, ≤64 speakers, ≤128 KB, else 400 INVALID_DIAR_STATE · up to 8 speakers count usually right, rarely off by more than 1; more than 8 not promised, count runs low; no independent audit DATA audio, transcripts, voice embeddings exist only inside request (stream deletes delivered content as it goes); none of it ever used for training · kept: account record (key hash, email and its verification state), billing metadata (audio and billed seconds, timing, cost_center), signup-network counters, admin audit entries, support mail; never content · voice memory across requests = client's own diar_state: sealed, opened only by Verbi, stored nowhere by Verbi; deleting your copy is the deletion · no SOC 2 or ISO 27001 certification, no standing DPA (ask for one), no self-serve SLA · committed volume brings discounts, raised or removed caps, faster turnaround, SLA with credits · under a compliance regime use Verbi for audio it does not cover (development, testing, QA, evaluation) ENDPOINTS all take Bearer key unless marked public; request and response bodies in OpenAPI document · GET /health, public: liveness, single_response_max_file_minutes · GET /v1/pricing, public: prices, limits, free tier, modes · POST /v1/signup, public: mints key; optional {"email"} sends verification mail · GET /v1/verify?token=, public: link in that mail · POST /v1/verify/resend · POST /v1/email with {"email"}: attaches address to anonymous account (keeps anonymous grants until address verified) · GET /v1/billing: account id, plan, email_verified, payment_verified (whether a payment has been completed), balances, hours used this period, remaining_hours, period's reset time, concurrency_limit (authoritative) · GET /v1/usage?since=&until= · POST /v1/contact with {"message"} + optional "kind" (free text, e.g. feedback|bug|question) + optional "reply_to": reaches a human · POST /v1/billing/topup with {"hours":N≥10 whole} · GET and POST /v1/keys, DELETE /v1/keys/{key_id}: ≤10 active keys on one shared balance; secret shown once, when key is minted; rotation or leak = mint, swap, revoke; last key cannot be revoked · POST /v1/account/delete: empty body previews deletion (token valid 10 min + what would be lost); sending token confirms it · purchased credit on deleted account forfeited (confirming call states exact hours given up); to have it refunded instead write to contact@verbi.services before deleting · POST /v1/transcribe: raw audio is body INPUT anything ffmpeg decodes: wav, mp3, m4a, opus, ogg, flac, webm, video in mp4/webm/mkv/mov · audio decoded as it arrives, never seeked → in MP4 family metadata must be at front, else EMPTY_AUDIO with fix (ffmpeg -i in.m4a -c copy -movflags +faststart out.m4a) · everything normalised to 16 kHz mono 16-bit PCM; WAV that already is that passes through untranscoded · live WAV declares data-chunk size 0xFFFFFFFF · Opus at 24 kbit/s as accurate as that PCM at ~a tenth of the bytes → efficient upload for files and live sources alike · default: container's default track, all channels downmixed · speakers told apart by voice, never by channel; stereo never billed twice · ?track=<0–31> picks a track · ?channel=<1–8 of left|right|0–31>: one channel = that channel, several = submix, absent = all channels, empty → 400 · per-channel transcription = one request per channel · selection echoed back (channel as indices) · well-formed selection the file does not have → verdict TRACK_NOT_FOUND or CHANNEL_NOT_FOUND, nothing billed QUERY names exact: case-sensitive, untrimmed · parameter present but empty = error; no parameter may appear twice · socket URL takes identical parameters · mode · stream-utterances=true|false (default false): utterance stream as NDJSON lines on HTTP POST; socket implies true, refuses any other value · captions=srt|vtt|srt,vtt · stable-names=true|false (default false) · events=speech_only| (absent = every kind, the sounds included) · speech always recognised, transcribed, billed, reported; events chooses which other kinds reported alongside, never what is transcribed · speech_only reports speech events alone; list reports speech plus those kinds · audio_events and event frames then carry only those kinds, ids 1..N among them, event_id on every segment · events_settled_upto follows slowest reported kind: music ~1.2 s behind audio, vocals ~4 s, a sound ~a second plus its kind's minimum length · values speech, true, false, speech_only together with a kind, unknown kind (not offered), empty kind or repeated kind → 400 INVALID_PARAM · speech_segment_count and bill unchanged by events · cost_center · max_billed_speech_sec · track=<0-based> · channel= HEADERS X-Glossary · X-Verbi-Diar-State · X-Correlation-Id: ≤128 printable ASCII characters, echoed on every response and error; only a fingerprint of it stored; the id to quote when a cut response never delivered its request_id · on socket same three go in start message as fields "glossary", "diar_state", "correlation_id"; events may ride there too (spelled as on URL; given in both places, values must be identical) · repeated parameter, or repeated Authorization, X-Verbi-Diar-State, X-Glossary or X-Correlation-Id header → 400 INVALID_PARAM USAGE cost_center = 1–64 characters matching [a-z0-9][a-z0-9_-]*, else 400 INVALID_COST_CENTER before anything billed · label stored on usage row, usage grouped by it; account pays · request without one reports as (untagged) · GET /v1/usage requires both since and until, Unix seconds, since < until ≤ now−60 s; else 400 INVALID_PARAM, never adjusts them · interval half-open [since,until), by moment each second was drawn → adjacent intervals add up exactly · reports billed_speech_sec per label + account total; rows plus (untagged) partition total · stream crossing a bound splits like the money does · history runs from first billed second, never pruned, deleted with account · report's own limits in OpenAPI document GLOSSARY JSON array of ≤100 terms · free, per request, stateless; multi-word and non-ASCII terms fine · common words gain nothing, can hijack similar rare ones · write spelling you want back · recognizer steered by spoken form; your spelling restored where those words occur together, exactly and only there (& and + read "and"; term spoken as several words comes back as spoken) · term needs ≥2 letters or digits; character rules in OpenAPI document · empty, over-long or unrepresentable term, >100 terms, or two terms spoken identically → 400 INVALID_GLOSSARY (with invalid_terms and invalid_chars) before anything billed · accepted glossary has every term applied (glossary_applied) SPEAKERS diarization runs by default, free (?mode=diar_off skips it) · words carry speaker (display key); response carries speakers · new confirmed voice gets short opaque alias of two tokens ("Viwa Teyo"): identifier only, unique within request and its carried lineage; unrelated requests enroll voices afresh · continuity = diar_state: one entry per speaker, key-set in cleartext, voice evidence sealed (only Verbi reads it, Verbi stores none of it) · curate it as data: rename a key → becomes label on words and captions · drop an entry → forget a voice · assemble a map from entries across tokens you kept (old tokens = version history) · entries that are one voice merge once audio confirms it (two parallel streams reconcile that way) · repeat a key across entries → pool them up front · minting never reuses a visible key, but unrelated lineages can collide → treat aliases as scoped opaque ids · merged entries list every key in speakers[].keys: look speaker up by whole key-set, never by label alone · indistinguishable voices merge; telling them apart takes knowledge from outside the audio, recorded on key-set · diar_state_reused (present only when a token was presented) = full | none; none = older model, voices enrolled afresh, you enroll again · tokens scoped to account and model · ?stable-names=true derives names from voice, salted per account: best effort, never comparable across accounts · labels = acoustic estimates, not identity TOKEN SHAPE "v2..", JSON = {"emb": "", "created_at": , "e": [[["Viwa Teyo"], ""], ...]}: one entry per speaker, key list in clear, evidence opaque · curating = decode, edit, re-encode · rename = replace key string ([["Viwa Teyo"], "..."] → [["Alice"], "..."]) · forget = delete entry · pool = give two entries same key · merge tokens = concatenate their "e" lists (same account, key id and "emb" only) · sealed evidence must come back byte-for-byte, else 400 INVALID_DIAR_STATE · two speaker tables: diar_state frames list speakers[].name plus keys; JSON response and final frame list speakers[].speaker plus keys; same display key under two field names CAPTIONS complete srt and vtt strings built from word timestamps; request every format you need at once · cues break at sentence ends, speaker changes, long pauses, length · with diarization, speaker's display key (or your renamed key) opens that speaker's turn; under diar_off and vad_diar_off no prefixes (to subtitle use default: under vad_diar_off music can surface as caption text) · cue ≤2 lines × 42 chars and ≤7 s; cues never overlap; reading time bought from silence at 20 chars/s; no words dropped (layout details in OpenAPI document) · & < > escaped as entities · words and renamed keys = untrusted text · on stream frames carry cue deltas per format: concatenate them · numbering continuous, WEBVTT header comes once · cue waits for its speaker label, so may trail text by a clustering pass; cue that could still grow waits for next speech or end; stragglers come in final frame · streamed = whole-file render · abnormal end loses held last cue JSON RESPONSE body = leading whitespace then one object; check error_code even on a 200 · text · words [{word,start,end,confidence,speaker?}]: times = seconds in original audio; starts within ±0.2 s; ends clamped to speech region; speaker present only with diarization; confidence = good right/wrong signal on clean speech, weak one on music-bedded audio · language + language_confidence: dominant language of output text, ISO 639-1, and [0,1]; both omitted when text too short · language_segments only when ≥2 languages occur: [{start,end,language,confidence}], sentence-level runs · speaker_count + speakers [{speaker,word_count,total_speech_sec,keys?}]: each instant has one speaker; keys = full key-set · diar_state (when diarization ran), diar_state_reused (when a token was presented) · audio_duration_sec, billed_speech_sec, speech_ratio, mode · audio_events [{id,kind,start,end,...}], chronological, ids 1..N; kinds: speech = span of detected speech, closing 0.5 s after speech ends, with its text; like every event carries no billing figure · music (a passage of at least 0.2 s, from the sound detector — meter's own music judgement decides bill and is not reported, so a music event is not a billing statement); includes sung vocals; untranscribed, unbilled; independent of speech: speech over a music bed = speech event inside a music event, neither called "background"; pause in music ends event, so song with a pause = two events; opens ~2 s after music starts, closes ~1 s after it stops; less reliable on telephone- or radio-quality audio · vocals (from the sound detector: a voice carried by music, sung or rapped — rap counts — and unaccompanied singing; a passage of at least 3 s, opening ~4 s after voice starts); untranscribed, unbilled; usually inside a music event; rapped line also a speech event inside it; theatrical or sing-song speech can occasionally count, ordinary speech rarely does · a sound (knock, cough, …), reported by default or when listed, as {id,kind,start,end}: passage of that sound, at least its kind's minimum length; says what recording contained, detected from audio signal only; indicative, not a substitute for human verification; quiet or brief sound may be missed; sound can also read as music · audio_events empty under vad_diar_off; events never change bill · events_settled_upto: below it no event of a reported kind can still open (in JSON response equals duration; absent under vad_diar_off) · engine: opaque id, same for every mode, changes if and only if engine changes · track and channel (when sent) · max_billed_speech_sec and capped (when ceiling sent), capped_reason (only when capped) · remaining_hours: estimate (/v1/billing authoritative) · speech_segment_count: number of speech events, whatever events reports · request_id · timing {processing_sec, rtfx, plus queue/upload/latency diagnostics — OpenAPI} · captions {"srt","vtt"} when requested · glossary_applied when X-Glossary sent STREAM FRAMES text frames on socket, one JSON object each · normal end = zero or more frames then exactly one terminal frame (final or error) · transport cut has no terminal frame · delivered content deleted on server → keep every delta · type=transcript: segments_delivered cumulative including this frame, never decreases · segments [{event_id?,start,end,text,words,billed_speech_sec,language?,language_confidence?}]: each a new stretch, in order, sent once, never revised; join with spaces · frames carrying only captions omit segments · speaker labels arriving with no text go out as type=turns {speaker_turns} instead of transcript frame: either first clustering pass's history, or later pass labelling text already delivered · each frame carries wait_sec and time_to_delta_sec · type=event {id,kind:speech|music|vocals|a sound,edge:open|close,t}; close adds duration_sec (its t minus the open's) and duration_sec_total (running total for same kind) = duration, not a bill: bill is on transcript segments · each edge sent once, open first, when final · kinds settle independently, so sort by t: speech opens ~2 s after onset or with its first decoded piece, music ~2 s after it starts, vocals ~4 s (its minimum passage 3 s), a sound ~a second after it has run its kind's minimum length FIELDS PER TYPE transcript frame: billed_speech_sec (this frame's), billed_speech_sec_total (running, including it), events_settled_upto · event frame: events_settled_upto (+ duration_sec and duration_sec_total on a close) · settled frame: events_settled_upto only · turns frame: speaker_turns only · stats, diar_state, error frames: neither · final frame: totals · events_settled_upto = one nondecreasing watermark, absent everywhere under vad_diar_off: keep last value seen, never reset either on a frame without it · type=settled {events_settled_upto} alone: sent when watermark has moved ≥0.5 s past last value any frame carried, checked ~every second; through silence it keeps watermark moving · type=stats ~every 10 s: window_sec 60, rolling wait_* and delta_latency_* figures, service health only, can be ignored · type=diar_state {diar_state: the token, speakers, pass}: re-issued as state advances, always before first transcript or turns frame showing a new speaker · speaker_turns [[start,end,"key"],...] = span deltas, not per-segment labels · span holding no word (a breath, or a word's tail on a turn edge) not sent; stream's speaker summary does not count it · overlay spans by time: new span overlapping a held one replaces overlapped part; only overlap = same speaker's span re-sent with later end · delivered range never changes speaker → new voice joining mid-stream wears its nearest known label until next clustering pass; on long stream that first stretch can stay mislabelled · first spans after ~30–60 s of speech (covering earlier speech retroactively), then within ~a frame · stream words carry no speaker: attribute by containing span · captions on frames = cue deltas · type=final: last segments and cues plus totals: audio_duration_sec, billed_speech_sec (its own last segments'), billed_speech_sec_total (request's), speech_ratio, mode, engine, speech_segment_count, timing, speakers with total_speech_sec and keys but no word counts, language (word-weighted vote), remaining_hours, request_id, and cap, selection and state fields; never carries text or events · type=error: error fields and request_id only WS wss://verbi.services/v1/transcribe: same path and query parameters as POST, utterance stream only · client that cannot open a socket gets same frames from HTTP POST /v1/transcribe?stream-utterances=true as NDJSON lines; that path's rules in OpenAPI description of stream-utterances parameter START first text frame = {"type":"start","api_key","glossary","diar_state","correlation_id","events"}, everything but "type" optional · client that can set headers may send them on upgrade and skip start message · value given in both places must be identical · unknown fields refused; ?key= in URL refused · socket silent for 30 s after upgrade is closed AUDIO binary frames carry request-body bytes, in order, ~100 ms each, in any accepted container (MediaRecorder WebM/Opus chunks concatenate correctly) · efficient shape = Opus at 24 kbit/s in chunks of ~100 ms (MediaRecorder audioBitsPerSecond 24000; ffmpeg -c:a libopus -b:a 24k): as accurate and as quick to text as 16 kHz mono PCM16 behind a WAV header with both sizes 0xFFFFFFFF, at ~a tenth of the bytes · {"type":"end"} marks end of audio (sent before any audio = empty request → EMPTY_AUDIO) · keep socket open until final frame: closing = disconnecting (what was delivered is billed) · audio after end message, or any other text frame → 4400 SILENCE no audio for 30 s before end message, while Verbi is reading, = disconnect (socket closes, no frame) · send silence when source is quiet; it is audio, not billed PUSHBACK Verbi stops reading when its pipeline is behind (cold decode tier can pause ~2 min) · your writes stall, audio waits in your buffer, none lost · read frames while sending, or long upload deadlocks · server pings every 5 s · small clients: let a write wait as long as pushback lasts; write timeout of a few seconds (common in small-device socket libraries) drops connection, loses what was accepted but not yet delivered · largest text frame = `diar_state` frame, its token grows to 128 KB · under mode=diar_off neither `diar_state` nor `turns` frames sent OUT text frames, one JSON object each · clean end closes with 1000 · request_id rides upgrade response (X-Request-Id) and reason of every close except 1012 REFUSALS one {"type":"error",...} frame with usual envelope, then close with 4000+status (4401, 4402, 4429 with retry_after_sec, 4400; 4500 = server failure) CLOSE 1012 = service restarted (emergency deploy path; normal promote never cuts a live socket) · reconnect with last diar_state and resume: buffered audio arrives late, not lost · reconnect = new request (new request_id, ids and timestamps restart from audio it receives, own ceiling), needs decodable start: fresh container header (WAV: just the 44 bytes again; Ogg or WebM: stream's header pages) before next bytes · no audio acknowledged, but every delivered segment's end says how far transcript got: client that kept its audio re-sends from there (live recipe does), cut costs at most a clipped word at seam; client that kept nothing loses what was accepted but not yet delivered ERRORS classify by what failed, never by when · envelope = HTTP status + {error_code,error_message,request_id} (socket: one error frame, then close with 4000+status); nothing processed or billed · transcription client handles: 401 UNAUTHORIZED · 402 QUOTA_EXCEEDED (balance exhausted; carries a payment block) · 429 TOO_MANY_CONCURRENT (with retry_after_sec) · 429 SIGNUP_LIMIT · 403 ACCOUNT_SUSPENDED · 400 INVALID_PARAM (names offender and rule), INVALID_GLOSSARY (with invalid_terms and invalid_chars), INVALID_DIAR_STATE, INVALID_MAX_BILLED_SPEECH, INVALID_COST_CENTER, INVALID_TRACK / INVALID_CHANNEL (grammar or range) · 503 SERVICE_UNAVAILABLE (back off and retry) · when every Verbi machine is busy, edge can hold a request up to ~40 s and then answer 503 with an EMPTY body and no request_id (socket's upgrade fails same way): treat like any 503; nothing billed · account endpoints' own codes in OpenAPI document: INVALID_JSON, PAYLOAD_TOO_LARGE, KEY_LIMIT, LAST_KEY, INVALID_CONFIRMATION, CONFIRM_FORFEIT_REQUIRED, REQUESTS_IN_FLIGHT, NO_EMAIL, ALREADY_VERIFIED, RESEND_RATE_LIMITED, INVALID_TOKEN, KEY_NOT_FOUND, INVALID_TOPUP_HOURS, INVALID_MESSAGE, PAYMENT_UNAVAILABLE, PAYMENT_UPSTREAM, MAIL_UPSTREAM VERDICTS answers where audio was accepted (a 200) and terminal object or frame carries error_code · EMPTY_AUDIO: nothing decodable (MP4 metadata at end gets remux hint) · FILE_TOO_LONG: over 60 minutes on single response · TRACK_NOT_FOUND / CHANNEL_NOT_FOUND: nothing billed · QUOTA_EXCEEDED: speech exceeds balance mid-request; delivered frames stay billed · ACCOUNT_SUSPENDED: account suspended while request ran; cut within 40 s, delivered frames stay billed; on socket error frame followed by close with 4403 · PROCESSING_FAILED RETRY on 429 wait retry_after_sec; on 502 or 503 back off (empty-bodied 503 included) · cut single response retried (not billed); cut stream = new request that may repeat paid audio · never retry a verdict · rejection can land mid-upload, server waits only briefly for rest of body: read response before reporting a broken writer · requests, httpx, aiohttp, curl surface response; urllib.request raises EPIPE and loses it → recheck key, balance, parameters before resending RECIPE NOTES live recipe: error frame ends run with its code (4429 refusal carries retry_after_sec: wait, then restart) · after a cut client re-sends, behind fresh WAV header, last three minutes it sent from last delivered segment's end (for PCM: seconds × 32000 bytes), then what stdin buffered meanwhile · close after end message re-sends undelivered tail and ends again · capture_offset_sec on every printed frame = where that request's audio starts in capture · capture-wide budgets under LIMITS · frames printed re-serialised, one per line · folder recipe: scans recursively for listed extensions, does not unpack archives · refusal = that file's result; re-run later · QUOTA_EXCEEDED or UNAUTHORIZED stop new files from starting while in-flight ones drain · text = segments across all frames · no batch-wide budget (per-file ceiling multiplies) · splitting a long file for single-response path instead: ffmpeg -v error -i long.mp3 -f segment -segment_time 3540 -c copy part%03d.mp3; offset each part's times by its start; send each part with diar_state previous part returned (as X-Verbi-Diar-State) so speakers keep their keys across parts; expect clipped words at seams · registry recipe: enroll from single-person audio only, on capture condition you will use · look people up by whole key-set; preserve ambiguity when one cluster maps to two names · keep snapshots before editing · uncontrolled token growth hits 64 speakers / 128 KB and is rejected · cost estimate: worst case = audio_duration_sec/3600 speech-hours; read remaining_hours first · support: quote request_id (X-Request-Id header of every response; also in every result, final frame and error) or a non-secret caller correlation ID (see HEADERS)