Endpoints

Everything you call lives on api.latency.cam. The docs you are reading are on latency.cam, which is a separate host, so they stay readable independently of the API.

For live calls, use the websocket, not these REST endpointswss://api.latency.cam/ws/stream is a complete voice agent — turn taking, transcription, the LLM, filler injection, synthesis and barge-in all run server side. The REST endpoints are the building blocks under it, useful for batch work and custom pipelines. Full protocol →

Authentication

Authorization: Bearer <YOUR_API_KEY>          # every /v1/* endpoint
wss://api.latency.cam/ws/stream?api_key=...  # the websocket

One header unlocks every endpoint. No rate limits, no quotas, no per-model gating, no concurrency caps — verified across 216 requests including 30-way parallel bursts, with zero 429s. The websocket takes the key in the URL because browsers cannot set headers on a WebSocket. GET /health needs no key at all.

Every error code, and what to do

CodeMeaningFix
401Missing or bad keyCheck the Authorization header, or ?api_key= on the websocket
403Only POST /v1/voices, when consent_confirmed is not trueYou need the speaker's permission. Licence requirement, not policy.
422Bad request: unknown model or voice, unsupported language, empty or undecodable inputThe message names the problem and usually lists the valid values
429Never returnedThere are no rate limits
500A bug hereThe response carries the exception type and message. Report it.
501Nothing returns this now. Voice cloning is live at POST /v1/voices.Use a preset voice, or clone your own with POST /v1/voices
502The reply model could not be reached, or the tier is not enabled hereUse fast or balanced, which always are. The message says which of the two cases it is.
503Something we depend on is temporarily unavailableThe message says which part, and whether retrying or a different parameter is the way round it. This is ours to fix, not yours.
504Only /v1/translate, on a very large batchSplit the batch, or set num_beams: 1
1008Websocket close code for a rejected keyA JSON error frame with the reason arrives before the close

All 79 of them, by what you are doing

Grouped by job rather than by URL prefix. Somebody asking "how do I make a phone number ring my agent" should not have to know the answer is filed under /v1/phone-numbers. These are the same groupings the dashboard uses, so a screen you found there and its endpoint here are named the same thing.

Live calls

WEBSOCKET /ws/stream alpha

One websocket per phone call. Push the caller audio in 20ms frames and play whatever comes back. Speech detection, turn taking, transcription, the LLM, filler injection, synthesis and barge-in all happen server side, so a working voice agent is a websocket and an audio pipe — not an orchestration layer you have to build. This is the endpoint to use for calls; the REST endpoints are the building blocks under it.

MeasuredPer turn, measured on the box: STT 40ms, LLM 587ms, number normalisation 1ms, time-to-first-audio 96ms — 724ms from the caller falling silent to the first byte of the reply, down from 3135ms on the previous hardware. Perceived wait is 3ms, because a cached filler plays the instant the turn ends. On about a quarter of turns the prosody model ends the turn at 82ms instead of waiting out the 500ms silence timeout, so the caller-visible figure is lower again. Barge-in cuts the bot off 200ms after the caller starts talking. Add your own network round trip on top: roughly 200ms for a caller in India today, and less once our compute moves there.

Parameters

NameTypeDefaultDescription
api_key reqstring (query)—Your API key, in the URL: wss://api.latency.cam/ws/stream?api_key=sk-lat-...
languagestringhiCaller language, e.g. hi, ta, te, bn, en-IN. Sent in the configure frame.
scriptstringromanScript for TTS input. Hindi sounds best from Roman text ("aapka order"); other languages use native.
One of: roman native
voicestring—Exact voice name, e.g. "Tamil (Male)". Omit for the default female voice of the language.
llmstringthe fast tierfast, balanced or precise. See /docs/models.
stt_modelstring—Omit this. The default identifies the caller's language itself and is the fastest option; there is no reason to change it.
allowed_languagesstring[]—Restrict which languages the agent will switch INTO, e.g. ["hi","ta","te"]. DEFAULTS TO EMPTY, which means the agent follows the caller into any language we can speak — leave it that way unless you want the others refused, because a caller who asks for a language outside the set is told the agent cannot speak it. Setting it does buy accuracy: detection errors cluster among closely related languages, so naming the ones your callers really use removes that class of mistake.
system_promptstring—Your agent instructions. Ask for replies under about 15 words; long replies sound robotic on a call.
audio_formatstringmulaw_8000mulaw_8000 is what telephony gives you. Applies to audio in BOTH directions.
One of: mulaw_8000 pcm_16000
silence_msinteger500Silence that ends a caller turn. Lower feels snappier but clips people who pause mid-sentence.
min_speech_msinteger200Ignore blips shorter than this, so a cough does not open a turn.
interrupt_min_wordsinteger3Roughly how much speech is needed to interrupt the bot. 0 means any sound cuts it off. Keep it at 3 or more for Indian callers, who backchannel with "haan" and "ji" constantly.
use_fillersbooleantruePlay a cached acknowledgement the moment a turn ends. This is what removes the perceived wait. Leave it on.
auto_language_detectbooleantrueFollow the caller when they change language mid-call. ON by default now: the default speech-to-text model identified the language correctly 44/44 times on real human speech with no language code, at both 16kHz and 8kHz. Detection is free — the model writes what it hears and the script IS the language — so this costs nothing. Turn it off to pin the reply to the configured language.
greetingbooleantruePlay a cached greeting on connect.
normalize_numbersbooleantrueRewrite digits as spoken words before synthesis. Leave it on or "15250" gets read wrong.
lock_voicebooleantrueKeep the same voice when the caller changes language. Voices are named per language ("Hindi (Female)", "Tamil (Female)") and each is a different speaker, so without this the agent changes identity at every switch — the caller hears a handover rather than a bilingual person. Measured on a live Telugu-Hindi-Tamil call, speaker-verification similarity to the opening voice was 0.818 with this on and 0.437 with it off, where two takes of one real human score 0.747 and two different people score 0.213. Turn it off only if you would rather each language be rendered by a voice native to it.
semantic_turnbooleantrueLet a prosody model end the turn early when it is confident the caller has finished, instead of always waiting out the 500ms silence timeout. It can only ever shorten the wait: about a quarter of turns end at 82ms instead of 510ms, and the timeout still covers the rest, so no turn is slower than with this off.
partial_transcriptsbooleantrueEmit transcript.partial while the caller is still talking, for live captions. Costs GPU but not latency: partials run in the background and the final transcript is produced by its own pass, measured at no change to time-to-final.
partial_every_msinteger400How often to re-transcribe while the caller speaks. Faster is mostly wasted work, since each partial re-reads the whole utterance rather than extending the last one.
partial_min_msinteger1000How much speech to collect before the first partial. Below about a second the transcriber has too little to identify the language from and guesses, usually into Latin script — measured first partials included "police is" for Marathi and "a dready." for Kannada.
language_switch_toolbooleantrueLet the agent switch language when the caller ASKS. Different from auto_language_detect, which follows what the caller is speaking: someone who says in Hindi "please speak in Tamil" is speaking Hindi, so detection is correct and useless. Costs an extra model round trip on the turns where it fires.
pronunciationsobject | array—How to say specific names. Either {"Asim": "असीम"} or the per-language form [{"term":"Asim","say_as":{"hi":"असीम","ta":"அசீம்"}}]. The synthesiser reads Latin text with its own language's spelling rules, so a name written in Latin is pronounced by guesswork. Only the text sent to the synthesiser is rewritten — transcripts and tool arguments keep the original spelling.

Returns

A mix of JSON text frames (events) and binary frames (bot audio in the format you configured). Binary frames are raw audio with no header — write them straight to the call.

Worth knowing
  • PROTOCOL: send {"type":"configure",...} first. Audio sent before configure is refused.
  • Then send binary frames of 20ms audio (160 bytes for 8kHz mulaw). Send them in real time; do not dump a whole file at once or turn detection will see one enormous utterance.
  • CONTROL FRAMES you can send: configure, say (make the bot speak arbitrary text — use it for opening lines or reading out a CRM lookup), say_as_caller (run a full turn from text as though the caller had said it, for scripting or replaying a conversation), reset (clear conversation history), ping, hangup.
  • EVENTS you will receive, in order for a normal turn: configured, speech_started, transcript.partial (repeatedly, while the caller is still talking), turn_end, filler_played, transcript.final, llm.reply, bot_audio_started, bot_audio_done, turn_complete.
  • BARGE-IN events: interrupted (the caller took the turn) then speech_abandoned (synthesis for the old turn stopped). Discard any audio you have buffered for the abandoned turn.
  • OTHER EVENTS: turn_empty (no speech recognised, nothing sent to the LLM), language_detected, language_switched (the agent changed language because the caller asked), tool_calls (which tools ran and what they changed), pronunciation_applied (which names were respelled), error, goodbye, pong.
  • turn_end reports ended_by: "timeout" when the 500ms silence elapsed, or "semantic(0.98)" when the prosody model ended it early, along with saved_ms. Useful for confirming the acceleration is real on your traffic rather than taking our word for it.
  • Every event carries a "turn" number. Audio and events for a turn that has been interrupted must be dropped — that is what the number is for.
  • MID-CALL LANGUAGE SWITCHING WORKS AND IS ON BY DEFAULT. A caller can start in Telugu, switch to Hindi, then to Tamil, and the agent follows each time. Verified on live calls. `language` is still worth setting as the starting language and as a tie-break between Hindi and Marathi, which share a script.
  • `allowed_languages` is empty by default and that is the right default: the agent follows the caller into any language we speak. Set it ONLY to deliberately refuse the rest — a caller asking for a language outside the set is told no. When you do know your callers, it is the cheapest accuracy win available, because a detection outside the set is reported but ignored.
  • If a detected language has no voice we keep the configured language instead of failing the turn, and the language_detected event says exactly why it did not switch.
  • The server decides when a turn ended, not you. That keeps the protocol simple and means a naive client cannot break turn taking.
  • Voice activity detection is energy based and adapts to the line noise in the first ~300ms of the call. It is good at turn taking and not good at distinguishing speech from sustained background noise, so a caller in a loud shop or with a television on can trigger it. A known gap, and next on our list.
  • PARTIAL TRANSCRIPTS ARE ON. transcript.partial arrives roughly every 400ms while the caller is still speaking, at about 42ms of GPU each and no measurable cost to the final transcript. A partial in a language this call cannot plausibly be in is dropped rather than shown, because on under a second of audio the transcriber guesses — so after a caller switches language, the first utterance in the new language has its partials suppressed while the final transcript still switches correctly. Naming your languages in allowed_languages removes even that.
  • TURN ENDING IS BOTH SEMANTIC AND TIMED. A prosody model is asked 40ms into the pause, and only a very confident answer ends the turn early. It is asked early on purpose: silence itself reads as "finished", so a longer pause makes the model worse rather than just slower — separation between finished and mid-sentence speech measured 0.176 at a 40ms pause against 0.029 at 500ms. About 3% of mid-sentence pauses still trigger a false end, which is recoverable because barge-in cancels the reply 200ms after the caller resumes.

Errors

CodeWhenFix
1008Bad or missing api_keyPass ?api_key=sk-lat-... in the websocket URL. The server sends a JSON error frame explaining this before closing.
0error event with stage="stt"|"llm"|"tts"The call stays open and the turn is abandoned. Read the message field; it carries the real exception. Play a fallback line to the caller.

Example

# Websockets are not curl-able. Minimal Python client:
import asyncio, json, websockets

async def call():
    url = "wss://api.latency.cam/ws/stream?api_key=" + KEY
    async with websockets.connect(url, max_size=None) as ws:
        await ws.send(json.dumps({
            "type": "configure",
            "language": "hi", "script": "roman",
            "audio_format": "mulaw_8000",
            "system_prompt": "You are a delivery helpdesk. Reply in Hindi, under 12 words.",
        }))
        async def send_caller_audio():
            # 160-byte frames of 8kHz mulaw, one every 20ms
            for frame in caller_frames():
                await ws.send(frame)
                await asyncio.sleep(0.02)
        asyncio.create_task(send_caller_audio())
        async for msg in ws:
            if isinstance(msg, bytes):
                play_to_caller(msg)       # raw audio, no header
            else:
                print(json.loads(msg))    # events

asyncio.run(call())

POST /telephony/{provider}/answer alpha

Point your Twilio, Plivo or Exotel number at this URL. It answers with whatever XML that provider expects, telling them to open a media websocket back to us. You do not write the XML and you do not choose the agent here — the agent is decided by which number was dialled, which you set with POST /v1/phone-numbers.

MeasuredAnswer document generated in under 2ms; it is string building, with one database read to find the agent for the dialled number.

Parameters

NameTypeDefaultDescription
provider reqstring (path)—Which carrier is calling this webhook. It decides the XML dialect in the response.
One of: twilio plivo exotel
Tostring (form)—The dialled number, as the provider spells it. Used to look up which agent answers. Twilio and Plivo send this; Exotel names it differently and we accept both spellings.
Fromstring (form)—The caller. Recorded on the call row so you can match it to a CRM contact.
CallSidstring (form)—The provider's call identifier. Stored alongside ours so a support ticket can be traced in either system.

Returns

XML in the provider's own dialect (TwiML for Twilio, Plivo XML for Plivo, an Exotel applet response), containing the wss:// URL for the media stream.

Worth knowing
  • BOTH GET AND POST WORK. Providers disagree about which verb a webhook uses, and some retry with the other one after a failure. Accepting both removes an entire category of silent misconfiguration.
  • The websocket URL we return carries a short-lived token, not your API key — so the answer document is safe even though it travels through the carrier.
  • If the dialled number has no agent mapped, the response is a spoken message rather than a hangup. A caller hearing "this number is not configured" is diagnosable; dead air is not.
  • Exotel is different in kind: it dials into a flow you build once in their dashboard, so you paste a flow ID into the provider account rather than a webhook URL. See /docs/telephony.

Example

# You do not call this — the carrier does. Set it as the voice webhook on your number:
#   https://api.latency.cam/telephony/twilio/answer
# Then map the number to an agent:
curl -X POST https://api.latency.cam/v1/phone-numbers \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"number": "+14155550100", "agent_id": "ag_...", "provider": "twilio"}'

# Verify what the carrier will receive:
curl "https://api.latency.cam/telephony/twilio/answer?To=%2B14155550100"

WEBSOCKET /telephony/{provider} alpha

Where the carrier sends the caller audio. Same engine as /ws/stream — turn taking, transcription, the LLM, fillers, synthesis and barge-in — wrapped in each provider's framing, which differs in base64 encoding, keepalive and how a stream identifier is echoed back.

Parameters

NameTypeDefaultDescription
provider reqstring (path)—Which framing to speak.
One of: twilio plivo exotel
token reqstring (query)—The short-lived token minted by the answer webhook. Not your API key.

Returns

Provider-framed audio, plus clear/checkpoint control messages when the bot is interrupted.

Worth knowing
  • ALL THREE PROVIDERS ARE 8kHz mulaw, which is why mulaw_8000 is the default audio format everywhere else in this API.
  • Barge-in has to be told to the carrier, not just to us: the provider has already buffered audio we no longer want played. Each one is sent its own stop instruction (Twilio "clear", Plivo "clearAudio", Exotel "clear") so an interrupted sentence actually stops in the caller's ear rather than only in ours.
  • Verified by 31 tests covering each provider's framing, including the interrupt path.
  • You never open this yourself. It exists in the docs so you can see there is no hidden orchestration layer — the carrier path and the direct path are the same engine.

Example

# Opened by the carrier, not by you. To exercise the same engine directly, use /ws/stream.

Speech

POST /v1/audio/speech alpha

Synthesise speech in 19 languages. This endpoint reaches every voice we have, including the nine announcement-only languages an agent is not offered. Set stream=true for live calls: audio arrives in chunks as it is generated, so playback starts long before synthesis finishes.

MeasuredTime to first audio 96ms streaming, 92-100ms across runs (the synthesis server on an our hardware). Non-streaming a short sentence takes about 2.7s. Real-time factor 0.83, so synthesis outruns playback and a live call cannot fall behind. Cached filler phrases return in 1-3ms with X-Cache: HIT.

Parameters

NameTypeDefaultDescription
input reqstring—Text to speak. Keep replies under ~15 words on a phone call.
language reqstring—Language code, e.g. hi, ta, te, bn, en. See GET /v1/models.
scriptstringnativeFor Hindi, Roman input ("aapka order aa gaya") is noticeably better than Devanagari. Other languages use native script.
One of: roman native
voicestring—Exact voice string from GET /v1/models, e.g. "Telugu (Male)". Omit and the female voice for that language is used.
modelstring—Omit it. There is one synthesiser and it is chosen for you.
emotionstring—Emotion tag. Also settable inline by putting the tag at the END of input.
formatstringpcm_24000mulaw_8000 for telephony — the downsampling is done here.
One of: pcm_16000 pcm_24000 mulaw_8000 wav
streambooleanfalseChunked audio. Required for live calls.
temperaturenumber0.6Lower is more consistent. 0.4 is good for fixed phrases.
normalizebooleantrueRewrite digits as spoken words first. Leave it on.

Returns

Raw audio in the requested format. Response headers carry X-Model-Used, X-Generate-Ms, X-Quality-Check and X-Cache.

Worth knowing
  • Emotion tags go at the END of the text: "Aapka payment ho gaya <happy>".
  • All 38 voices are verified working — every one was synthesised and quality checked in the audit.
  • The model occasionally runs away and produces overlong audio. A length bound and a quality check catch it, and X-Quality-Check reports the result. On a stream, a degenerate generation falls back to a non-streaming retry automatically.
  • Repeated short phrases should come from the filler cache instead: 1ms rather than ~2.5s. See GET /v1/fillers.

Errors

CodeWhenFix
422input is empty, or the voice/language is unknownCheck the voice string exactly matches one from GET /v1/models, including capitalisation and the bracketed gender.
503The TTS model or the synthesis server is not upCheck GET /health. If the box was just started, models take about 40s to warm.

Example

curl -X POST https://api.latency.cam/v1/audio/speech \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "Aapka EMI 15250 rupay ka hai, due date 4 tarikh hai.",
    "language": "hi",
    "script": "roman",
    "format": "mulaw_8000",
    "stream": true
  }' --output reply.raw

POST /v1/audio/transcriptions alpha

Transcribe caller audio. Accepts wav, mp3, flac, ogg and raw mulaw or PCM. Optionally detects the language first.

MeasuredDefault model (Our transcriber) on real human speech (FLEURS), given NO language code: 40-77ms per utterance, word error rate 0.113 over an 8kHz phone line and 0.097 at 16kHz, and it identified the language correctly 44 out of 44 times at both bandwidths. The model it replaced had to be TOLD the language and still scored 0.252 at 8kHz, took 305ms, and its own language detection managed 12% on the same task.

Parameters

NameTypeDefaultDescription
file reqfile—Audio file. Multipart form upload.
languagestring—Optional. With the default model you do NOT need this — it works the language out itself. Used only as a tie-break between languages that share a script (Hindi vs Marathi).
modelstring—Omit it. The default identifies the language itself and is the fastest.
sample_rateinteger—Required only for raw headerless audio.
detect_languagebooleanfalseRun language ID first and report it alongside the transcript.

Returns

{ text, model_used, inference_ms, engine_ms, language, requested_language, detected_language, script, candidate_languages, detection_source, language_probability, duration_s }

Worth knowing
  • You do not need to pass a language. The default model has a unified multi-script vocabulary, so it writes what it hears and the script it writes in IS the language — detection is a by-product of transcription and costs nothing.
  • detection_source tells you where the language came from: "transcriber" means the model worked it out, which is the accurate path.
  • script and candidate_languages exist because the ambiguity is real, not hidden: Hindi and Marathi share Devanagari, so a Devanagari transcript returns both. Pass `language` to break the tie.
  • For Hindi or Telugu specifically, whisper-hindi-large-v2 and whisper-telugu-large-v2 are about twice as accurate (Hindi WER 0.047 vs 0.104) but roughly 15x slower. Use them for transcription you are not waiting on.
  • Those two are single-language models. Send them anything else and you get nonsense — measured WER ~1.0 on the wrong language.
  • The optional large models load on demand and may evict each other, because the synthesis server permanently holds ~12.6GB of the 23GB GPU. The first call to one of them pays the load time.
  • Audio under 1 second transcribes poorly. 2 seconds or more is much better.

Errors

CodeWhenFix
422Empty file, undecodable audio, or audio shorter than 10msCheck the upload actually contains audio. For raw PCM or mulaw, pass sample_rate.
503A non-default model was requested and could not be loadedOmit the model parameter. The default is always available and is the fastest.

Example

curl -X POST https://api.latency.cam/v1/audio/transcriptions \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -F file=@call.wav \
  -F language=te

POST /v1/lid alpha

Spoken language identification across 42 Indian languages, using Vaani-LID. Use it when you do not know what the caller will speak.

Measured197ms once warm. The first call after a restart used to cost 27s; the model is now warmed at startup.

Parameters

NameTypeDefaultDescription
file reqfile—Audio. 2 seconds or more is much more reliable than 1.
top_kinteger3How many candidates to return.
restrict_tostring—JSON array or comma list of candidate codes. This is the single biggest accuracy lever — narrow it whenever you can.
sample_rateinteger—Required for raw headerless audio.

Returns

{ candidates: [{ language, confidence }], duration_s, warning? }

Worth knowing
  • Telugu against Tamil is reliable. Hindi against Urdu is not, because they are near-identical spoken.
  • Under 1 second of audio returns a warning field; treat the result as a guess.
  • The model was trained on 16kHz. It still works on 8kHz telephony audio but accuracy drops.

Errors

CodeWhenFix
422Empty or undecodable fileCheck the upload.
503LID model unavailableCheck GET /health.

Example

curl -X POST https://api.latency.cam/v1/lid \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -F file=@caller.wav \
  -F 'restrict_to=["ta","te","kn","ml"]'

POST /v1/normalize alpha

Rewrites digits, currency and dates into words with Indian grouping, so TTS says them correctly. Called automatically by /v1/audio/speech; exposed separately so you can inspect or override it.

Measured1-2ms.

Parameters

NameTypeDefaultDescription
text reqstring—Text containing numerals.
languagestringhiTarget language.
stylestringspokenspoken for quantities ("pandrah hazaar"), digits for account numbers read one by one, grouped for phone numbers read in pairs.
One of: spoken digits grouped
scriptstringnativeOutput script.
One of: roman native

Returns

{ text, coverage, replacements[] }

Worth knowing
  • Use style=digits for account and reference numbers. "spoken" would turn 4021 into "four thousand twenty one", which is wrong for an account number.
  • coverage tells you what fraction of the numerals it recognised. Below 1.0 means something was left alone.

Example

curl -X POST https://api.latency.cam/v1/normalize \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Rs 15,250 due on 4/11", "language": "hi", "script": "roman"}'

GET /v1/fillers alpha

Short acknowledgements rendered ahead of time and cached on disk. Play one the instant the caller stops speaking, then stream the real reply behind it. This does not reduce measured latency at all — it removes the silence the caller would otherwise sit through, which is the thing that actually makes a bot feel broken.

Measured1-3ms to serve, against about 2500ms to synthesise the same phrase. The websocket does this for you automatically.

Returns

{ fillers[], categories, languages, _how_to_use }

Worth knowing
  • Categories: ack, thinking, confirm, greeting.
  • GET /v1/fillers/pick/{language}?category=ack picks one for you, with a fallback chain so you never get silence.
  • GET /v1/fillers/{id}?format=mulaw_8000 returns the audio.
  • POST /v1/fillers/generate renders any that are missing.
  • /v1/audio/speech also checks this cache first: if your text matches a cached phrase you get it in 2ms with X-Cache: HIT.

Example

curl https://api.latency.cam/v1/fillers -H "Authorization: Bearer $LATENCY_API_KEY"

# pick one and play it the moment the caller stops talking
curl "https://api.latency.cam/v1/fillers/pick/hi?category=ack" \
  -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/voices alpha

Upload any audio or video file and get a voice the agent can speak in. The preset voices come from about fifty trained speakers and sound mechanical on a phone line; a cloned voice moves that ceiling to a recording you control. Send an iPhone memo, a WhatsApp forward, even a video — we take the clearest few seconds of speech out of it, transcribe it ourselves, and keep about nine seconds at 24kHz. You type no transcript, which matters because the synthesiser needs the audio AND its words, and a wrong transcript silently leaks part of your recording into every reply. The voice speaks every language we support, not only the one it was recorded in.

Parameters

NameTypeDefaultDescription
file reqfile—Any audio or video. wav, mp3, m4a, mp4, mov, ogg, amr, flac. Up to 60MB and 10 minutes; we keep about nine seconds of it.
name reqstring—A label so you can recognise it later.
consent_confirmed reqboolean—Must be true. You must have the speaker's explicit permission. The time you confirmed is recorded.
languagestring—The language of the recording. Detected automatically if omitted.

Returns

{ id, name, language, transcript, seconds, voice, prepared: { your_file_seconds, used_seconds, used_from_seconds, level_dbfs } }

Worth knowing
  • Set an agent's `voice` to the returned id. That is all — picking a cloned voice routes synthesis to the cloning engine automatically, so there is no second setting to keep in step.
  • A cloned voice keeps its identity across a mid-call language switch even with lock_voice off, because there is no per-language sibling to fall back to.
  • Preview it through POST /v1/voices/{id}/preview before you rely on it. That renders through a simulated 8kHz phone line, which is the only audition worth trusting — a voice can sound excellent in a browser and poor on the call.
  • Every model licence behind this forbids cloning a voice without the speaker's permission, so consent_confirmed is not a flag we could remove.

Errors

CodeWhenFix
403consent_confirmed is not trueYou need the speaker's permission. This is a licence requirement, not a policy choice.
422The file is silent, too short, or has no recognisable speechThe message says which. Record a couple of clear sentences in a quiet room.
503The cloning model is not installed on this deploymentPreset voices from GET /v1/models still work.

Example

curl -X POST https://api.latency.cam/v1/voices \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -F file=@my-voice.m4a -F name="Priya" -F consent_confirmed=true

GET /v1/voices alpha

The voices this account has cloned. Separate from the preset voices in GET /v1/models, which are still available.

Returns

{ voices: [{ id, name, language, transcript, seconds, voice }], count, available }

Worth knowing
  • `available` is false when the cloning model is not installed on the deployment.

Example

curl https://api.latency.cam/v1/voices -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/voices/{voice_id}/preview alpha

Returns a WAV of the voice speaking one line. Squashed through 8kHz mu-law by default, because that is what your callers hear — we measured our own audio codec costing more than an entire G.711 line, so auditioning at studio quality hides the only thing that matters. Pass a language to hear the same voice in another one.

Parameters

NameTypeDefaultDescription
textstring—What to say. A natural opening line in the chosen language if omitted.
languagestringhiAny of the two-way languages.
phone_linebooleantrueSet false to hear it at full quality, which is NOT what a caller gets.

Returns

audio/wav

Example

curl -X POST https://api.latency.cam/v1/voices/vc_abc123/preview \
  -H "Authorization: Bearer $LATENCY_API_KEY" -H 'Content-Type: application/json' \
  -d '{"language":"ta"}' --output preview.wav

DELETE /v1/voices/{voice_id} alpha

Removes the recording for good, which is what somebody withdrawing consent is asking for. The voice record itself is kept so finished calls that name it still read correctly rather than pointing at nothing.

Returns

{ deleted, recording_removed }

Example

curl -X DELETE https://api.latency.cam/v1/voices/vc_abc123 -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/fillers/pick/{language} alpha

Returns the audio bytes of a single cached filler in the language you ask for, chosen at random from that category so the agent does not repeat itself. This is the endpoint the live call uses.

Measured1.2ms median, 0.9–1.7ms over 5 runs. No model runs — it is a disk read of pre-rendered audio.

Parameters

NameTypeDefaultDescription
language reqstring (path)—e.g. hi, ta, te.
categorystringackack, greeting, thinking, or closing.
formatstringmulaw_8000mulaw_8000 goes straight to a phone line.
One of: wav pcm_16000 mulaw_8000

Returns

Raw audio bytes in the requested format.

Worth knowing
  • THIS IS THE SINGLE BIGGEST PERCEIVED-LATENCY WIN IN THE PRODUCT. The real reply takes 724ms to start. A filler starts in 3ms, so the caller never hears the gap. Without it the agent sounds broken even though every stage underneath is fast.
  • Randomised within the category on purpose: a fixed acknowledgement repeated every turn is more grating than silence.
  • If a language has no cached filler the response is a 404 rather than a synthesised one, because synthesising here would cost the exact latency the cache exists to hide.

Example

curl "https://api.latency.cam/v1/fillers/pick/hi?category=ack&format=mulaw_8000" \
  -H "Authorization: Bearer $LATENCY_API_KEY" --output filler.raw

GET /v1/fillers/{filler_id} alpha

Fetch one known filler rather than a random pick. Useful when you have chosen the wording you want and need it to be the same every time, for example an opening line.

Measured1.2ms. Same cache as the pick route.

Parameters

NameTypeDefaultDescription
filler_id reqstring (path)—From GET /v1/fillers.
formatstringmulaw_8000wav, pcm_16000 or mulaw_8000.

Returns

Raw audio bytes.

Example

curl https://api.latency.cam/v1/fillers/hi_ack_01 \
  -H "Authorization: Bearer $LATENCY_API_KEY" --output filler.raw

POST /v1/fillers/generate alpha

Synthesise the filler set and write it to disk. Run once after adding a language or changing the default voice. Everything else in the product reads the cache this produces.

Parameters

NameTypeDefaultDescription
languagestring—Just one language. Omit to build all 19.
forcebooleanfalseRe-synthesise fillers that already exist. Without this, existing files are left alone.

Returns

{ generated[], skipped[], seconds }

Worth knowing
  • Takes minutes for all 19 languages, because it is real synthesis. It is a maintenance operation, not a request path.
  • Safe to run while calls are in progress: files are written to a temporary name and moved into place, so a call never reads a half-written file.

Example

curl -X POST "https://api.latency.cam/v1/fillers/generate?language=hi&force=true" \
  -H "Authorization: Bearer $LATENCY_API_KEY"

Language

POST /v1/translate alpha

our translation engine distilled 200M, running on CPU so it never competes with the GPU that serves your calls. English to any Indian language and back, plus Indic to Indic.

MeasuredEnglish to Tamil 1056ms with the default beam search. A batch of 3 with num_beams=1 took 392ms total. Indic to Indic 1309ms because it pivots through English. 12 concurrent requests all completed, in 14.5s.

Parameters

NameTypeDefaultDescription
text reqstring | string[]—One string, or an array to translate as a batch. Batching is much faster per item.
source_language reqstring—e.g. en, hi, ta, te. See GET /v1/translate/languages.
target_language reqstring—e.g. hi, ta, te, en.
num_beamsinteger51 is about twice as fast with slightly worse output.
backendstringstandardstandard is more faithful and does not paraphrase. llm falls back to the reply model provider and needs no local model.
One of: standard llm
llm_modelstringthe fast tierOnly used when backend=llm.

Returns

{ translations[], source_language, target_language, source_tag, target_tag, pivoted_through_english, model, latency_ms, queued_ms, inference_ms, backend }

Worth knowing
  • Indic to Indic pivots through English, which is an extra hop and shows as pivoted_through_english: true. Direct one-hop translation between two Indian languages is not available yet.
  • Text only. To translate speech: POST /v1/audio/transcriptions, then this, then POST /v1/audio/speech.
  • Inference is serialised per worker process on purpose. Running two translations in one process deadlocked inside torch; queueing is both correct and faster. queued_ms tells you how long you waited for a slot.
  • Nothing is rejected under load. There is no queue-depth limit and no 429.

Errors

CodeWhenFix
422Unsupported language code, or source equals targetUse a code from GET /v1/translate/languages.
503The translation sidecar is not runningsudo systemctl restart it2-translate on the box, or retry with backend="llm".
504A very large batch exceeded the timeoutSplit the batch, or set num_beams=1.

Example

curl -X POST https://api.latency.cam/v1/translate \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Your order will arrive tomorrow between 10 am and 12 pm.",
    "source_language": "en",
    "target_language": "ta"
  }'

GET /v1/translate/languages alpha

The 23 codes accepted by /v1/translate, and which directions are direct versus pivoted.

Returns

{ languages[], count, directions[], backends{} }

Example

curl https://api.latency.cam/v1/translate/languages -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/chat/completions alpha

Point any OpenAI SDK at https://api.latency.cam/v1 and it works. Backed by the reply model provider.

Measured387-660ms for a short reply with the fast tier.

Parameters

NameTypeDefaultDescription
modelstringthe fast tierthe fast tier is the fastest and the right default for calls.
One of: the fast tier the balanced tier the precise tier
messages reqarray—Standard OpenAI message list. system, user and assistant roles.
max_tokensinteger150Keep it low. Long replies sound robotic on a call.
temperaturenumber0.7Standard.

Returns

An OpenAI chat.completion object, plus latency_ms.

Worth knowing
  • the precise tier needs the Anthropic use-case form submitted for this AWS account in the the reply model provider console. Until then the reply model provider returns ResourceNotFoundException and this endpoint reports it verbatim.
  • stream is accepted but not yet implemented; the reply arrives whole.
  • Nova handles Indian languages well enough for call replies, and you can always ask it to reply in the caller language in your system prompt.

Errors

CodeWhenFix
422Unknown model, or no user messageThe error lists the available model ids.
502the reply model provider rejected the callThe message carries the the reply model provider exception. For claude, submit the use-case form. If credentials are missing, set them in /etc/latency-api.env.

Example

curl -X POST https://api.latency.cam/v1/chat/completions \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "fast",
    "max_tokens": 60,
    "messages": [
      {"role": "system", "content": "Reply in Hindi, under 12 words."},
      {"role": "user", "content": "Mera order kab aayega?"}
    ]
  }'

Agents

POST /v1/agents alpha

An agent is a saved call configuration. Every setting the live call accepts is a setting an agent can carry, read from the same object, so there is no mapping layer between the two to drift apart.

Parameters

NameTypeDefaultDescription
name reqstring—1–120 characters.
configobject—Any of the 51 settings from GET /v1/config-schema. An unknown key is a 422 that names the key.
notestring—Why you made this version. Shows up in the version history.
publishbooleantrueWhether this version answers calls immediately. false stages it as a draft.

Returns

{ id, name, version, published_version, config, created_at }

Worth knowing
  • SAVING PUBLISHES BY DEFAULT. A draft-only default reads as safer and behaves worse: someone edits a prompt, hears no change on the next call, and concludes the product is broken. Pass publish:false when you actually want to stage.
  • An unknown setting is rejected rather than dropped. Silently ignoring "systemPrompt" would hand someone an agent that ignores its prompt with no clue why.

Errors

CodeWhenFix
422Unknown or wrongly typed setting in configThe message names the offending key and lists the valid ones. GET /v1/config-schema is the full list.

Example

curl -X POST https://api.latency.cam/v1/agents \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Delivery helpdesk",
    "config": {
      "language": "hi",
      "allowed_languages": ["hi", "ta", "en"],
      "system_prompt": "You are a delivery helpdesk for {{company}}. Reply in under 15 words.",
      "variables": {"company": "Neureek"},
      "goal": "Successful when the caller is told when their order arrives.",
      "lock_voice": true
    }
  }'

GET /v1/agents alpha

Every agent on your account, with its current and published version numbers.

Parameters

NameTypeDefaultDescription
include_archivedbooleanfalseInclude agents you have archived.

Returns

{ agents[], count }

Example

curl https://api.latency.cam/v1/agents -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/agents/{agent_id} alpha

The agent with its full configuration. Pass a version to read an older one.

Parameters

NameTypeDefaultDescription
agent_id reqstring (path)—The agent id.
versioninteger—A specific version. Omit for the latest.

Returns

{ id, name, version, published_version, config, archived, created_at, updated_at }

Errors

CodeWhenFix
404No such agent, or no such versionGET /v1/agents/{id}/versions lists what exists.

Example

curl https://api.latency.cam/v1/agents/ag_7f3a2b -H "Authorization: Bearer $LATENCY_API_KEY"

# an older version
curl "https://api.latency.cam/v1/agents/ag_7f3a2b?version=2" -H "Authorization: Bearer $LATENCY_API_KEY"

PATCH /v1/agents/{agent_id} alpha

Appends a version. Nothing is ever overwritten, so rollback is publishing an older version rather than restoring a backup, and the record of what the agent used to say survives. A version is a few kilobytes of JSON, so this costs almost nothing.

Parameters

NameTypeDefaultDescription
agent_id reqstring (path)—The agent id.
config reqobject—The COMPLETE configuration. It replaces, it does not merge.
notestring—What changed and why.
publishbooleantrueMake this version live.

Returns

{ id, version, published_version, config }

Worth knowing
  • THE CONFIG IS REPLACED, NOT MERGED. Merging would make a setting impossible to remove: sending a config without allowed_languages would keep the old restriction for ever, so an agent could never be un-restricted. Read the agent, change what you want, send the whole thing back.
  • A CALL PINS ITS VERSION AT THE START AND KEEPS IT TO THE END. Without that, editing an agent while calls are in flight changes their behaviour halfway through — a caller gets one reply from the old prompt and the next from the new one. Verified end to end: a call started on v1 still answers from v1 after the agent is edited to v2, while a new call answers from v2. So an edit is safe to make at any time.

Errors

CodeWhenFix
404No such agentCheck the id.
422Unknown or wrongly typed settingThe message names the key.

Example

curl -X PATCH https://api.latency.cam/v1/agents/ag_7f3a2b \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"config": {"language": "hi", "system_prompt": "Reply in under 10 words."},
       "note": "shorter replies"}'

GET /v1/agents/{agent_id}/versions alpha

Every version, newest first, with its note and whether it is the published one.

Parameters

NameTypeDefaultDescription
agent_id reqstring (path)—The agent id.

Returns

{ versions[{version, note, published, created_at}], count, published_version }

Example

curl https://api.latency.cam/v1/agents/ag_7f3a2b/versions -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/agents/{agent_id}/publish alpha

Point the agent at a version. Rolling back a bad prompt is publishing the previous version, which takes effect on the next call and needs no deploy.

Parameters

NameTypeDefaultDescription
agent_id reqstring (path)—The agent id.
version reqinteger—Which version to make live. 1 or greater.

Returns

{ id, published_version }

Worth knowing
  • Calls already in progress keep the version they started on, so publishing during traffic is safe.

Errors

CodeWhenFix
404No such agent or versionCheck /versions.

Example

curl -X POST https://api.latency.cam/v1/agents/ag_7f3a2b/publish \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" -d '{"version": 3}'

POST /v1/agents/{agent_id}/archive alpha

Archiving hides the agent and stops it taking calls, but keeps it and its history. There is no hard delete for agents, because a deleted agent takes its call history's meaning with it.

Parameters

NameTypeDefaultDescription
agent_id reqstring (path)—The agent id.
restorebooleanfalsetrue to un-archive.

Returns

{ id, archived }

Example

curl -X POST "https://api.latency.cam/v1/agents/ag_7f3a2b/archive" \
  -H "Authorization: Bearer $LATENCY_API_KEY"

# undo
curl -X POST "https://api.latency.cam/v1/agents/ag_7f3a2b/archive?restore=true" \
  -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/config-schema alpha

All 51 call settings, read out of the engine's own configuration object rather than a hand-written copy — names, types, defaults, groups and a note each.

Measured1ms. It is reflection over a dataclass, computed once.

Returns

{ settings[{name, type, default, group, note, enum?}], count, groups[] }

Worth knowing
  • THE DASHBOARD FORM IS BUILT FROM THIS RESPONSE, not from a copy. A setting added to the engine therefore appears in the product without anyone editing the dashboard, and a setting the dashboard has no label for still appears under its raw name in an advanced section. That last property is the one that matters: the alternative is a setting that exists in the engine and cannot be reached from the product.
  • Use it to validate a config before POSTing an agent, instead of discovering a typo through a 422.
  • Verified by 24 tests asserting the schema matches the dataclass, so the two cannot drift.

Example

curl https://api.latency.cam/v1/config-schema -H "Authorization: Bearer $LATENCY_API_KEY" | jq '.settings[] | {name, type, default}'

POST /v1/agents/draft alpha

Describe a business in plain language and get back a config: a system prompt written for a phone call, the languages, what counts as success, and what to read off the transcript afterwards. Nothing is created — the response is a config you POST to /v1/agents if you like it.

Parameters

NameTypeDefaultDescription
brief reqstring—What the agent is for, in a sentence or two. 10–4000 characters.
languagestring—The language callers speak, e.g. hi, ta. Worth passing — it is the one thing a brief usually leaves implicit.
modelstringthe fast tierWhich model writes it.

Returns

{ config, ignored[], suggested_name, model, note, draftable_fields[] }

Worth knowing
  • IT ONLY WRITES SEVEN OF THE FIFTY-ONE SETTINGS, and that is the point. Everything omitted keeps a default that came from a measurement: interrupt_min_words is 3 because Indian callers backchannel with "haan" and "ji" constantly, semantic_turn is on because it can only shorten a turn, lock_voice is on because changing voice mid-call sounds like being handed to a different person. A model asked to fill in fifty-one fields produces plausible values for all of them and quietly undoes those decisions.
  • THE REAL JOB IS THE PROMPT, not saving typing. Someone who has written prompts for a chat window writes four polite sentences, which is unusable on a phone call, and nothing about the blank form tells them so. The drafter encodes what works on a call: replies under about fifteen words, one question per turn, no lists or symbols because every character is spoken aloud.
  • THE OUTPUT IS VALIDATED BEFORE YOU SEE IT. Keys outside the draftable list are dropped and reported in `ignored`, and what remains goes through the same check as a hand-written config. A suggestion that failed on save would be worse than no suggestion.
  • It will not invent facts. A brief that does not mention a refund window produces a prompt that does not state one, because the agent would say it out loud as fact.
  • Non-deterministic by nature. Two identical briefs give two different drafts. Treat it as a first version, which is what the version history is for.

Errors

CodeWhenFix
502The model was unreachable, or returned something that is not a configNothing was saved. Rephrase the brief, or write the agent by hand — the drafter is a convenience, not a dependency.

Example

curl -X POST https://api.latency.cam/v1/agents/draft \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "brief": "We are a courier in Pune. Ring customers whose parcel is out for delivery and check the address is right.",
    "language": "hi"
  }'

Knowledge base

POST /v1/agents/{agent_id}/knowledge alpha

Give an agent something to quote from — a price list, a returns policy, a menu. The file is split into chunks, embedded, and stored against this agent only.

MeasuredRetrieval at query time is 26.5ms for the embedding search. Ingestion is roughly a second per hundred kilobytes and happens once, off the call path.

Parameters

NameTypeDefaultDescription
agent_id reqstring (path)—Which agent gets this document.
file reqfile—PDF, DOCX, TXT, MD, CSV or HTML.

Returns

{ doc_id, filename, chunks, characters }

Worth knowing
  • KNOWLEDGE IS PER-AGENT, NOT A SHARED LIBRARY. A shared library invites someone to upload a document, hear the agent not use it, and conclude retrieval is broken. Scoping it to the agent makes the relationship visible: the document is inside the thing that reads it.
  • RETRIEVAL USES EMBEDDINGS (cross-lingual embeddings), NOT KEYWORD SEARCH. Measured on a 19-language question set, embeddings found the right chunk in the top 3 for 100% of questions against 58% for BM25 keyword matching — and BM25 scored zero on Kannada, Malayalam and Marathi, because a caller asking in Kannada does not repeat the document's exact words and those scripts do not stem the way English does. Keyword search is not a slightly worse option here; it does not work at all for most of our languages.
  • A caller asking in Tamil can be answered from an English document, because the embedding space is shared across languages. You do not need a translated copy per language.
  • Chunking is by paragraph with overlap, so a sentence split across a boundary is still findable.

Errors

CodeWhenFix
404No such agentCreate the agent first.
422Unsupported file type, or no extractable text (for example a scanned PDF with no text layer)The message says which. We do not OCR — run the scan through OCR first.

Example

curl -X POST https://api.latency.cam/v1/agents/ag_7f3a2b/knowledge \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -F file=@returns-policy.pdf

GET /v1/agents/{agent_id}/knowledge alpha

What this agent can quote from, with chunk counts.

Parameters

NameTypeDefaultDescription
agent_id reqstring (path)—The agent id.

Returns

{ documents[{doc_id, filename, chunks, characters, created_at}], count }

Example

curl https://api.latency.cam/v1/agents/ag_7f3a2b/knowledge -H "Authorization: Bearer $LATENCY_API_KEY"

DELETE /v1/agents/{agent_id}/knowledge/{doc_id} alpha

Deletes the document and its chunks and embeddings. Takes effect on the next call.

Parameters

NameTypeDefaultDescription
agent_id reqstring (path)—The agent id.
doc_id reqstring (path)—From the list endpoint.

Returns

{ deleted, chunks_removed }

Errors

CodeWhenFix
404No such document on this agentCheck the list endpoint.

Example

curl -X DELETE https://api.latency.cam/v1/agents/ag_7f3a2b/knowledge/doc_91c4 \
  -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/agents/{agent_id}/knowledge/search alpha

Run the exact retrieval the live call runs, and see what comes back. This is how you find out whether a wrong answer on a call was retrieval or the prompt.

Measured26.5ms median for the search itself.

Parameters

NameTypeDefaultDescription
agent_id reqstring (path)—The agent id.
query reqstring—What the caller asked, in any language.
top_kinteger3How many chunks to return. 1–10.

Returns

{ results[{text, score, filename, doc_id}], count }

Worth knowing
  • SAME CODE PATH AS THE CALL, deliberately. A debug endpoint that searched differently would send you looking in the wrong place.
  • If the right chunk is here but the caller got a wrong answer, the problem is the prompt. If it is not here, the problem is the document or the chunking. That is the whole diagnostic.

Example

curl -X POST https://api.latency.cam/v1/agents/ag_7f3a2b/knowledge/search \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "refund kitne din mein milega?", "top_k": 3}'

Call history

GET /v1/calls alpha

Every call, newest first, with duration, language, turn count, goal verdict and extracted variables.

Parameters

NameTypeDefaultDescription
agent_idstring—Only calls for one agent.
limitinteger50How many rows.
offsetinteger0For paging.

Returns

{ calls[], count }

Worth knowing
  • A call row is written when the call starts and updated as it goes, so a call in progress is visible rather than appearing only once it ends.
  • languages is a list, not a string: a call that started in Telugu and moved to Hindi carries both, in order.

Example

curl "https://api.latency.cam/v1/calls?limit=20" -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/calls/{call_id} alpha

The whole call: every turn with its transcript and reply, which tools ran and what they returned, language switches, the goal verdict with its reasoning, and per-turn latency.

Parameters

NameTypeDefaultDescription
call_id reqstring (path)—The call id.

Returns

{ id, agent_id, agent_version, from, to, duration_s, languages[], turns[], variables{}, goal{}, recording_url?, timings{} }

Worth knowing
  • agent_version is on the row, so a call's behaviour can be read against the exact configuration that produced it rather than against whatever the agent says today. Without it, a bug found in an old call is not reproducible.
  • Per-turn timings are the real measured ones for that call, not the benchmark figures. This is where you check our latency claims against your own traffic.

Errors

CodeWhenFix
404No such call on your accountCheck the id. Calls are scoped to your account.

Example

curl https://api.latency.cam/v1/calls/call_8823ad -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/calls/{call_id}/recording alpha

The recording, if the agent was configured to keep one. By default it redirects to a short-lived signed URL rather than proxying the bytes, so the audio comes from storage near the listener instead of through the API host.

Parameters

NameTypeDefaultDescription
call_id reqstring (path)—The call id.
redirectbooleantruetrue returns a 307 to a signed URL. false returns the URL as JSON, which is what you want from a server.

Returns

307 to a signed URL, or { url, expires_in } when redirect=false.

Worth knowing
  • RECORDING IS OFF BY DEFAULT (record_audio: false). Recording a phone call without telling the other party is illegal in many places, so the default cannot be "on" — that would make the safe path the one you have to discover. Turn it on per agent once your script has the disclosure in it.
  • Recordings are stored off the call path, in object storage placed nearer the listener than the service is, and handed out as a short-lived signed link so a browser can play one without the storage being public.
  • Verified with a real audio round-trip: uploaded, signed, fetched back, byte-identical.

Errors

CodeWhenFix
404No such call, or the agent was not recordingSet record_audio: true on the agent — and put the disclosure in your script.

Example

# from a server: get the URL, do not follow the redirect
curl "https://api.latency.cam/v1/calls/call_8823ad/recording?redirect=false" \
  -H "Authorization: Bearer $LATENCY_API_KEY"

# or just download it
curl -L https://api.latency.cam/v1/calls/call_8823ad/recording \
  -H "Authorization: Bearer $LATENCY_API_KEY" --output call.wav

Receiving calls

POST /v1/phone-numbers alpha

Say which agent answers when this number is dialled. This is the mapping the carrier webhook reads, and it is the whole of inbound configuration.

Parameters

NameTypeDefaultDescription
number reqstring—In E.164, e.g. +14155550100. 4–32 characters.
agent_id reqstring—Who answers.
providerstring—Which carrier owns the number. Used to pick the XML dialect if the webhook does not say.
One of: twilio plivo exotel
labelstring—Your name for it, e.g. "Main support line".

Returns

{ number, agent_id, provider, label }

Worth knowing
  • Re-posting the same number changes where it points. There is no separate update call, because "route this number here" is the same intent whether or not a route already exists.
  • Numbers are matched flexibly on lookup, so a carrier that sends 14155550100 without the plus still finds a number stored as +14155550100. Carriers are inconsistent about this and a caller should not hear "not configured" because of a plus sign.

Errors

CodeWhenFix
404No such agentCreate the agent first.

Example

curl -X POST https://api.latency.cam/v1/phone-numbers \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"number": "+14155550100", "agent_id": "ag_7f3a2b",
       "provider": "twilio", "label": "Main support line"}'

GET /v1/phone-numbers alpha

Every number and the agent it rings.

Returns

{ numbers[{number, agent_id, agent_name, provider, label}], count }

Example

curl https://api.latency.cam/v1/phone-numbers -H "Authorization: Bearer $LATENCY_API_KEY"

DELETE /v1/phone-numbers/{number} alpha

Stop an agent answering this number. Calls to it then hear a spoken "not configured" message rather than dead air, which is diagnosable.

Parameters

NameTypeDefaultDescription
number reqstring (path)—URL-encode the plus: %2B14155550100.

Returns

{ deleted }

Errors

CodeWhenFix
404That number is not routedCheck the list endpoint.

Example

curl -X DELETE https://api.latency.cam/v1/phone-numbers/%2B14155550100 \
  -H "Authorization: Bearer $LATENCY_API_KEY"

Making calls

GET /v1/provider-accounts/fields alpha

The credential fields for Twilio, Plivo and Exotel, with which ones are secret. The dashboard builds its connect form from this, so the form cannot fall out of step with what the dialler actually reads.

Returns

{ providers{twilio[], plivo[], exotel[]}, encryption_available, note }

Worth knowing
  • Twilio needs account_sid and auth_token. Plivo needs auth_id and auth_token. Exotel needs five fields including a flow_id, because Exotel dials into a flow you build once in their dashboard rather than fetching a webhook.
  • Fields marked secret are encrypted before storage and never returned. encryption_available tells you whether the encryption key is actually configured on the box — if it is false, do not paste a production token.

Example

curl https://api.latency.cam/v1/provider-accounts/fields -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/provider-accounts alpha

Store the credentials we dial out with, and the number outbound calls come from. Secret fields are encrypted at rest and cannot be read back.

Parameters

NameTypeDefaultDescription
provider reqstring—Which carrier.
One of: twilio plivo exotel
from_number reqstring—The number outbound calls display. Must be one the carrier lets you originate from.
credentials reqobject—The fields from GET /v1/provider-accounts/fields, e.g. {"account_sid": "AC...", "auth_token": "..."}.
labelstring—Your name for this account.

Returns

{ id, provider, from_number, label, fingerprints{} }

Worth knowing
  • SECRETS ARE WRITE-ONLY. A short fingerprint is kept so you can tell which credential is stored without the value being retrievable. An API that reads a token back is a token you have to assume is leaked.
  • The credentials are not verified at this point — a wrong token surfaces as a campaign that fails on its first call, with the carrier's own error on the call row.

Errors

CodeWhenFix
422Unknown provider, or a required credential field missingThe message names the missing field. GET /v1/provider-accounts/fields is the list.
503Encryption is not configured on the boxRather than store a token in the clear, we refuse. Set the encryption key.

Example

curl -X POST https://api.latency.cam/v1/provider-accounts \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "twilio",
    "from_number": "+14155550100",
    "credentials": {"account_sid": "ACxxxx", "auth_token": "xxxx"},
    "label": "Twilio main"
  }'

GET /v1/provider-accounts alpha

Your carrier accounts, with credential fingerprints instead of credentials.

Returns

{ accounts[{id, provider, from_number, label, fingerprints{}, created_at}], count }

Example

curl https://api.latency.cam/v1/provider-accounts -H "Authorization: Bearer $LATENCY_API_KEY"

DELETE /v1/provider-accounts/{account_id} alpha

Removes the account and its stored credentials.

Parameters

NameTypeDefaultDescription
account_id reqstring (path)—The account id.

Returns

{ deleted }

Errors

CodeWhenFix
404No such accountCheck the list endpoint.

Example

curl -X DELETE https://api.latency.cam/v1/provider-accounts/pa_3d81 \
  -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/contact-lists alpha

Who to call, and what the agent should know about each of them. Accepts either JSON contacts or pasted CSV text — a customer pastes a CSV, an integration posts JSON, and forcing either to use the other's shape is friction for no gain.

Parameters

NameTypeDefaultDescription
name reqstring—1–120 characters.
contactsarray—e.g. [{"number": "+919876543210", "name": "Asim", "order_id": "A-1"}]. Everything other than the number becomes a variable the prompt can use.
csvstring—Raw CSV text with a header row. Send this or contacts, not both.

Returns

{ id, name, contacts, skipped[], variables_found[] }

Worth knowing
  • THE NUMBER COLUMN IS FOUND BY NAME from the things people actually call it — number, phone, phone_number, mobile, msisdn, contact. Requiring an exact header means a customer's CRM export fails for a reason they cannot see.
  • EVERY OTHER COLUMN BECOMES A VARIABLE. A file with name and order_id columns produces an agent that can say both, with no mapping step: your prompt uses {{name}} and {{order_id}}.
  • Rows with an unusable number are skipped and returned in skipped[] rather than failing the upload. One bad row in three thousand should not mean re-exporting the file.
  • Duplicate numbers within a list are collapsed, so a contact who appears twice in the export is not called twice.

Errors

CodeWhenFix
422No rows, or no recognisable number columnThe message lists the column names we accept.

Example

curl -X POST https://api.latency.cam/v1/contact-lists \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "October EMI reminders",
    "csv": "phone,name,amount,due_date\n+919876543210,Asim,15250,4 October\n+919876543211,Priya,8400,7 October"
  }'

GET /v1/contact-lists alpha

Your lists with their sizes.

Returns

{ lists[{id, name, contacts, created_at}], count }

Example

curl https://api.latency.cam/v1/contact-lists -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/contact-lists/{list_id} alpha

The contacts, with the variables attached to each.

Parameters

NameTypeDefaultDescription
list_id reqstring (path)—The list id.

Returns

{ id, name, contacts[{number, variables{}}], count }

Errors

CodeWhenFix
404No such listCheck the list endpoint.

Example

curl https://api.latency.cam/v1/contact-lists/cl_44a2 -H "Authorization: Bearer $LATENCY_API_KEY"

DELETE /v1/contact-lists/{list_id} alpha

Removes the list and its contacts. Calls already made keep their own record.

Parameters

NameTypeDefaultDescription
list_id reqstring (path)—The list id.

Returns

{ deleted }

Errors

CodeWhenFix
404No such listCheck the list endpoint.

Example

curl -X DELETE https://api.latency.cam/v1/contact-lists/cl_44a2 -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/dnc alpha

Numbers we will never dial, whatever a campaign says. Checked at dial time rather than at list upload, so adding a number here stops a campaign that is already running.

Parameters

NameTypeDefaultDescription
numbers reqstring[]—One or more numbers. At least one.
reasonstring—Why, e.g. "asked not to be called". Worth recording — a regulator asks.

Returns

{ added, numbers[] }

Worth knowing
  • CHECKED AT DIAL TIME, NOT AT UPLOAD. This is the whole point: a suppression list that only filtered on upload would let a running campaign keep calling someone who just asked you to stop.
  • Matching is on the normalised number, so +91 98765 43210 and 919876543210 are the same entry. A suppression list that could be defeated by formatting is not a suppression list.

Example

curl -X POST https://api.latency.cam/v1/dnc \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"numbers": ["+919876543210"], "reason": "asked not to be called"}'

GET /v1/dnc alpha

Every suppressed number with its reason and when it was added.

Returns

{ numbers[{number, reason, created_at}], count }

Example

curl https://api.latency.cam/v1/dnc -H "Authorization: Bearer $LATENCY_API_KEY"

DELETE /v1/dnc/{number} alpha

Take a number off do-not-call. Do this only with a record of consent.

Parameters

NameTypeDefaultDescription
number reqstring (path)—URL-encode the plus: %2B919876543210.

Returns

{ deleted }

Errors

CodeWhenFix
404Not on the listCheck GET /v1/dnc.

Example

curl -X DELETE https://api.latency.cam/v1/dnc/%2B919876543210 -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/campaigns alpha

Take a contact list, an agent and a carrier account, and call everyone — at a set concurrency, inside a calling window, retrying no-answers.

MeasuredConcurrency is capped at 6 because the GPU fits 11 simultaneous calls and inbound traffic must not be starved by a campaign. Verified by 45 tests including the window, retry and cancel paths.

Parameters

NameTypeDefaultDescription
name reqstring—1–120 characters.
agent_id reqstring—Who makes the calls.
list_id reqstring—Who to call.
account_id reqstring—Which carrier account to dial with.
agent_versioninteger—Pin a version for the whole campaign. Omit to use the published one at creation time.
concurrencyinteger3Simultaneous calls. 1–6.
attempts_maxinteger2Tries per contact, including the first. 1–5.
retry_after_sinteger1800Wait before retrying a no-answer. 60–86400.
window_startstring09:00Earliest local time to call.
window_endstring21:00Latest local time to call.
timezonestringAsia/KolkataWhich clock the window is on.
ring_timeout_sinteger30Give up on an unanswered ring after this. 10–120.
startbooleanfalseBegin immediately. Default is to create it paused so you can check it first.

Returns

{ id, name, status, contacts, concurrency, window, created_at }

Worth knowing
  • CREATED PAUSED BY DEFAULT. A campaign that dials three thousand people the moment it is created is the one mistake in this API that cannot be undone — the calls have happened. So starting is a separate, deliberate act.
  • THE CALLING WINDOW IS IN THE CONTACT'S TIMEZONE, and 09:00–21:00 Asia/Kolkata is the default because calling outside those hours is both rude and, in several jurisdictions, unlawful. Outside the window the campaign holds rather than fails, and resumes on its own.
  • THE AGENT VERSION IS PINNED FOR THE WHOLE CAMPAIGN. Editing the agent mid-campaign would otherwise mean the first thousand calls and the last thousand were different experiments, and the results could not be compared.
  • Every number is checked against do-not-call at dial time, not at creation.
  • Retries only fire for no-answer and busy. A completed call is not retried, and neither is a number the carrier rejected as invalid — retrying that just burns your carrier reputation.

Errors

CodeWhenFix
404No such agent, list or provider accountThe message says which one.
422Concurrency above 6, or a malformed window or timezoneThe message gives the valid range.

Example

curl -X POST https://api.latency.cam/v1/campaigns \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "October EMI reminders",
    "agent_id": "ag_7f3a2b",
    "list_id": "cl_44a2",
    "account_id": "pa_3d81",
    "concurrency": 3,
    "window_start": "10:00",
    "window_end": "19:00",
    "timezone": "Asia/Kolkata"
  }'

# check it, then start it
curl -X POST https://api.latency.cam/v1/campaigns/cm_9911/start -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/campaigns alpha

Every campaign with its status and progress.

Returns

{ campaigns[{id, name, status, contacts, attempted, completed, goal_met}], count }

Example

curl https://api.latency.cam/v1/campaigns -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/campaigns/{campaign_id} alpha

Status, settings, progress counts and why it is not dialling right now if it is not — outside the window, paused, or waiting on retries.

Parameters

NameTypeDefaultDescription
campaign_id reqstring (path)—The campaign id.

Returns

{ id, name, status, agent_id, agent_version, contacts, attempted, in_flight, completed, goal_met, window{}, holding_because? }

Worth knowing
  • holding_because is the field to read when a campaign looks stuck. "outside calling window until 10:00 IST" is an answer; a status of "running" with nothing happening is not.

Errors

CodeWhenFix
404No such campaignCheck the list endpoint.

Example

curl https://api.latency.cam/v1/campaigns/cm_9911 -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/campaigns/{campaign_id}/calls alpha

Every attempt, with outcome, attempt number and the call id to read in full.

Parameters

NameTypeDefaultDescription
campaign_id reqstring (path)—The campaign id.
statusstring—Filter, e.g. completed, no_answer, failed, pending.
limitinteger200How many rows.

Returns

{ calls[{call_id, number, status, attempt, goal_met, duration_s}], count }

Example

curl "https://api.latency.cam/v1/campaigns/cm_9911/calls?status=no_answer" \
  -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/campaigns/{campaign_id}/start alpha

Begin dialling. Also resumes a paused campaign, picking up where it left off rather than starting again — contacts already attempted are not called twice.

Parameters

NameTypeDefaultDescription
campaign_id reqstring (path)—The campaign id.

Returns

{ id, status }

Worth knowing
  • If you are outside the calling window, the status becomes running and dialling begins when the window opens. That is deliberate: you should be able to set a campaign going at midnight for the morning.

Errors

CodeWhenFix
404No such campaignCheck the id.
409Already finished or cancelledCreate a new campaign.

Example

curl -X POST https://api.latency.cam/v1/campaigns/cm_9911/start -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/campaigns/{campaign_id}/pause alpha

Stop starting new calls. Calls already connected are left to finish — cutting off a live conversation to honour a pause would be worse for the person on the phone than letting it end.

Parameters

NameTypeDefaultDescription
campaign_id reqstring (path)—The campaign id.

Returns

{ id, status, in_flight }

Worth knowing
  • in_flight tells you how many calls are still running, so you know when the campaign is truly quiet.

Errors

CodeWhenFix
404No such campaignCheck the id.

Example

curl -X POST https://api.latency.cam/v1/campaigns/cm_9911/pause -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/campaigns/{campaign_id}/cancel alpha

Abandon the campaign. Pending contacts are dropped and it cannot be restarted. Live calls finish. Separate from pause because "stop for now" and "never dial these people" are different intentions and collapsing them into one button gets one of them wrong.

Parameters

NameTypeDefaultDescription
campaign_id reqstring (path)—The campaign id.

Returns

{ id, status, dropped }

Errors

CodeWhenFix
404No such campaignCheck the id.

Example

curl -X POST https://api.latency.cam/v1/campaigns/cm_9911/cancel -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/calls/outbound alpha

One call, to one person, with one agent — no contact list and no campaign. This is the test dial behind the button in the agent editor, and it is also the endpoint for a call your own application triggers: an order shipped, a payment failed, a ticket escalated.

Parameters

NameTypeDefaultDescription
to_number reqstring—Who to ring. E.164 or a bare Indian ten-digit number.
agent_id reqstring—Which agent talks.
account_id reqstring—Which carrier account to dial through.
agent_versioninteger—Pin a version. Omit for the published one.
variablesobject—Values for the prompt, e.g. {"name": "Asim", "order_id": "A-1"}. Same mechanism a campaign uses to personalise from a contact list.
ring_timeout_sinteger30Give up on an unanswered ring. 10–120.

Returns

202 with { status: "dialing", to_number, from_number, provider, provider_call_id, agent_id, agent_version, note }

Worth knowing
  • NO RETRIES, NO CALLING WINDOW, NO CONCURRENCY ACCOUNTING. Those belong to campaigns, which exist to call a lot of people responsibly. This is one call: if nobody answers, that is the answer. Retrying here would quietly turn a test button into an auto-dialler with no calling hours.
  • THE DO-NOT-CALL LIST IS STILL CHECKED, and a suppressed number is a 409. A test call is a real call to a real person, so there is no exception for it — a suppression list with an exception is not a suppression list.
  • The caller ID comes from the account's rotation pool, so a test dial advances the rotation exactly as a campaign call would. That is deliberate: it means the number you hear it arrive from is a number your campaigns really use.
  • THERE IS NO CALL RECORD UNTIL IT IS ANSWERED. The row appears in GET /v1/calls when the provider connects the audio, because before that nothing was said. A ring that nobody picks up leaves nothing behind but your carrier's log.
  • The websocket is authenticated by a single-use token, never your API key — same arrangement as a campaign call. See /docs/telephony.

Errors

CodeWhenFix
404No such agent, or no such provider accountThe message says which one.
409The number is on your do-not-call listDeliberate. Remove it with DELETE /v1/dnc/{number} only if you have a record of consent.
422The carrier rejected the call permanently — unverified caller ID, bad credential, invalid numberThe message carries the provider's own words.
502The carrier failed in a way worth retryingTry again. The message includes their error.
503PUBLIC_BASE_URL is not set on the serverWithout a public address the carrier cannot fetch instructions, so the call would connect to silence. Refused rather than attempted.

Example

curl -X POST https://api.latency.cam/v1/calls/outbound \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to_number": "+919876543210",
    "agent_id": "ag_7f3a2b",
    "account_id": "pa_3d81",
    "variables": {"name": "Asim", "order_id": "A-1029"}
  }'

PUT /v1/provider-accounts/{account_id}/numbers alpha

Give an account several numbers to dial from. Calls are handed out round-robin, which spreads the load evenly and keeps any single number from being scored as a spam source.

MeasuredRound-robin verified even by construction: seven picks across a pool of three returned each number in order and wrapped, with no number used more than one time more than any other.

Parameters

NameTypeDefaultDescription
account_id reqstring (path)—The provider account.
numbers reqstring[]—Every number to dial from, in order. At least one. The first is also the fallback.

Returns

The updated account, including from_numbers and rotation_cursor.

Worth knowing
  • WHY ROTATE. Carriers and handset spam filters score a number by how much it calls. One number placing three thousand calls in a morning gets flagged and stops connecting; six numbers sharing that load do not. The customer sees this as an answer rate that collapses for no visible reason, which is easy to blame on the agent.
  • WHY ROUND-ROBIN AND NOT RANDOM. Random selection is measurably uneven over a few hundred calls — with six numbers the busiest gets noticeably more than the quietest purely by chance, which is the one property rotation exists to remove. Round-robin is exactly even.
  • PUT REPLACES THE POOL, it does not merge. With a merge there would be no way to remove a number, and the number you most want to remove is the one that just got blocked.
  • Every attempt records the number it went out on, so "which of my numbers stopped connecting" is answerable afterwards instead of a guess. It is on the campaign call rows.
  • The rotation cursor is stored, so it survives a restart rather than resetting to the first number every deploy.
  • Every number must be one the carrier lets you originate from. We do not verify that here — a wrong one surfaces as the carrier rejecting that call.

Errors

CodeWhenFix
404No such accountCheck GET /v1/provider-accounts.
422An empty listAn account needs at least one number to call from.

Example

curl -X PUT https://api.latency.cam/v1/provider-accounts/pa_3d81/numbers \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"numbers": ["+918000000001", "+918000000002", "+918000000003"]}'

Analytics

GET /v1/analytics/summary alpha

Calls, talk time, goal rate, and the measured latency of your own traffic broken into stages. This is where you check our speed claims against your calls rather than taking our word for it.

Parameters

NameTypeDefaultDescription
sincestring—ISO date. Omit for all time.
untilstring—ISO date.
agent_idstring—One agent only.

Returns

{ summary{}, daily[], latency{ttfa_ms, turn_ms, stt_ms, llm_ms, tts_ms, totals, sampled}, note }

Worth knowing
  • goal_met_rate counts only calls that were GIVEN a goal. Dividing by every call would make an agent look worse for the calls nobody asked to be scored, and better by simply removing the goal.
  • Latency percentiles come from per-call metrics over a bounded recent sample, and the response says so in `sampled`. A figure computed over all time would never move after a fix.
  • The distinction worth reading is median against p95. An agent that averages 700ms but has a p95 of six seconds feels broken to the person on that call, and the average hides them.

Example

curl "https://api.latency.cam/v1/analytics/summary?since=2026-09-01" \
  -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/analytics/breakdown alpha

Counts, average length and goal rate, grouped by language, how the call ended, agent, or agent version.

Parameters

NameTypeDefaultDescription
bystringlanguageWhat to group on.
One of: language ended_by agent_id agent_version
sincestring—ISO date.
untilstring—ISO date.
agent_idstring—Narrow to one agent first.

Returns

{ by, rows[{value, calls, avg_seconds, goal_met, goal_scored, goal_met_rate}], count }

Worth knowing
  • by=agent_version is the one that earns its place: it answers whether the edit you made last week helped, which is the question a version history alone cannot answer.
  • The grouping column is whitelisted rather than interpolated into SQL. A reporting endpoint that accepts a column name from a query string is how one becomes an injection.

Errors

CodeWhenFix
422An unsupported `by` valueThe message lists the four that work.

Example

curl "https://api.latency.cam/v1/analytics/breakdown?by=agent_version" \
  -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/analytics/reindex alpha

Re-reads every transcript into the search index. Needed after restoring a backup or importing calls, and harmless otherwise.

Returns

{ indexed, seconds }

Worth knowing
  • Runs against the live database and takes seconds to minutes with the call count. Search results are incomplete while it runs, not wrong.

Example

curl -X POST https://api.latency.cam/v1/analytics/reindex -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/analytics/analyse alpha

Takes a sample of calls that did not meet their goal, reads the transcripts, and returns recurring themes with the call ids behind each one and a suggested fix.

Parameters

NameTypeDefaultDescription
sincestring—ISO date.
untilstring—ISO date.
agent_idstring—One agent only.
sampleinteger20How many failed calls to read. 3–60.

Returns

{ themes[{theme, why, fix, call_ids[], count}], sampled, model }

Worth knowing
  • EVERY THEME CARRIES THE CALL IDS IT CAME FROM, so a claim can be checked against the transcript rather than believed. A summary without them would be an opinion about your calls that you cannot verify.
  • It reads a bounded sample rather than everything, because the cost is per token and the themes stop changing quickly. `sampled` says how many it actually read.
  • It reads transcripts, so this is a model seeing what your callers said. Worth knowing before pointing it at a sensitive line.

Errors

CodeWhenFix
502The model was unreachableThe message carries the provider error. Nothing is stored either way.

Example

curl -X POST https://api.latency.cam/v1/analytics/analyse \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "ag_7f3a2b", "sample": 20}'

POST /v1/boards alpha

A board is a question about your calls, saved so you can ask it again tomorrow. The filters object is free-form and stored as given, so the dashboard decides what a board contains without that being a schema change here.

Parameters

NameTypeDefaultDescription
name reqstring—What this board answers, e.g. "Tamil calls this week". 1–120 characters.
filtersobject—Whatever you want to come back to. The dashboard stores {since, until, agent_id, widgets[]}.

Returns

{ id, name, filters, created_at }

Worth knowing
  • A board stores the QUESTION, not the answer. Opening it re-reads the analytics endpoints live, so a board is never a stale snapshot.
  • Why this exists at all: re-entering the same date range and grouping every morning means that after a week nobody bothers, and the number stops being watched.

Example

curl -X POST https://api.latency.cam/v1/boards \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Tamil calls this week",
       "filters": {"agent_id": "ag_7f3a2b", "widgets": ["language", "ended_by"]}}'

GET /v1/boards alpha

Your boards with their filters.

Returns

{ boards[{id, name, filters, created_at}], count }

Example

curl https://api.latency.cam/v1/boards -H "Authorization: Bearer $LATENCY_API_KEY"

DELETE /v1/boards/{board_id} alpha

Removes the saved filters. No call data is touched.

Parameters

NameTypeDefaultDescription
board_id reqstring (path)—The board id.

Returns

{ deleted }

Errors

CodeWhenFix
404No such boardCheck the list endpoint.

Example

curl -X DELETE https://api.latency.cam/v1/boards/bd_69da5b -H "Authorization: Bearer $LATENCY_API_KEY"

Webhooks

GET /v1/webhooks/events alpha

The event list, the signing scheme with a worked verification example, and the retry policy. Read this first — it is the only page you need to integrate webhooks.

Returns

{ events{}, signature{header, timestamp_header, algorithm, why, example}, retries{backoff_seconds[], disabled_after_consecutive_failures, timeout_seconds} }

Worth knowing
  • SEVEN EVENTS: call.completed, call.goal_met, call.goal_missed, call.transferred, call.voicemail, campaign.finished, campaign.failed.
  • SIGNATURE IS HMAC-SHA256 OVER "<timestamp>.<raw body>", in X-Latency-Signature with the timestamp in X-Latency-Timestamp. The timestamp is inside the signature so a captured delivery cannot be replayed forever — reject anything more than a few minutes old.
  • Verify against the RAW BODY, before any JSON parsing. Re-serialising the parsed object changes the bytes and the signature will never match.
  • RETRIES ARE 10s, 30s, 2m, 10m, 20m — five tries over roughly twenty minutes. Long enough to ride out a deploy at your end, short enough that a delivery either arrives while it still matters or is visibly given up on. A webhook is switched off after 20 consecutive failures; re-enable it with POST /v1/webhooks/{id}/enable.

Example

curl https://api.latency.cam/v1/webhooks/events -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/webhooks alpha

Tell us where to POST when something happens. The signing secret is returned once.

Parameters

NameTypeDefaultDescription
url reqstring—Where to POST. HTTPS, publicly reachable.
eventsstring[]["*"]Which events, or ["*"] for all. Names from GET /v1/webhooks/events.
descriptionstring—Your note about what this feeds.

Returns

{ id, url, events[], secret, active, created_at } — the secret appears in this response only.

Worth knowing
  • THE SECRET IS SHOWN ONCE. Store it when you create the webhook; we keep only what is needed to sign, so there is no endpoint that reads it back.
  • Private and internal addresses are refused, the same guard the tools feature uses. A webhook pointed at 169.254.169.254 would be a way to make our server read its own cloud credentials and post them to you.

Errors

CodeWhenFix
422A malformed URL, a private or internal address, or an unknown event nameThe message says which. Event names come from GET /v1/webhooks/events.

Example

curl -X POST https://api.latency.cam/v1/webhooks \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/latency",
       "events": ["call.completed", "campaign.finished"],
       "description": "into the CRM"}'

GET /v1/webhooks alpha

Every subscription, whether it is active, and its recent failure count.

Returns

{ webhooks[{id, url, events[], active, description, consecutive_failures, created_at}], count }

Worth knowing
  • active:false means it was switched off after repeated failures. consecutive_failures says how close a working one is to that.

Example

curl https://api.latency.cam/v1/webhooks -H "Authorization: Bearer $LATENCY_API_KEY"

DELETE /v1/webhooks/{hook_id} alpha

Stop delivering to this URL and forget its secret.

Parameters

NameTypeDefaultDescription
hook_id reqstring (path)—The webhook id.

Returns

{ deleted }

Errors

CodeWhenFix
404No such webhookCheck the list endpoint.

Example

curl -X DELETE https://api.latency.cam/v1/webhooks/wh_2f81 -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/webhooks/{hook_id}/enable alpha

Re-enable a webhook that was disabled after repeated failures, once the receiving end is fixed.

Parameters

NameTypeDefaultDescription
hook_id reqstring (path)—The webhook id.
activebooleantruefalse to switch one off deliberately.

Returns

{ id, active }

Worth knowing
  • Deliveries that were given up on are not replayed. Read GET /v1/webhooks/deliveries to see what was missed, and use the search and call endpoints to catch up.

Errors

CodeWhenFix
404No such webhookCheck the list endpoint.

Example

curl -X POST https://api.latency.cam/v1/webhooks/wh_2f81/enable -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/webhooks/deliveries alpha

The delivery log: which event, which webhook, what status came back, how many attempts and the error if there was one.

Parameters

NameTypeDefaultDescription
webhook_idstring—One webhook only.
limitinteger100How many rows.

Returns

{ deliveries[{id, webhook_id, event, status, attempts, error, next_try_at, created_at}], count }

Worth knowing
  • This exists so a missing event can be traced rather than guessed at. "We never received it" is answerable from here: either we tried and your server refused, or we never sent it.

Example

curl "https://api.latency.cam/v1/webhooks/deliveries?limit=20" \
  -H "Authorization: Bearer $LATENCY_API_KEY"

POST /v1/webhooks/{hook_id}/test alpha

Fires a sample event at your URL through the real queue and the real signing path, so a signature you can verify here is a signature you can verify in production.

Parameters

NameTypeDefaultDescription
hook_id reqstring (path)—The webhook id.

Returns

202. The result appears in GET /v1/webhooks/deliveries.

Worth knowing
  • DELIBERATELY NOT A SPECIAL CASE. A test path that skipped the queue or the signature would prove nothing about the real one — it would pass while production failed.
  • Answers 202 and does the work in the background, so a slow or dead receiver does not hold the request open.

Errors

CodeWhenFix
404No such webhookCheck the list endpoint.

Example

curl -X POST https://api.latency.cam/v1/webhooks/wh_2f81/test -H "Authorization: Bearer $LATENCY_API_KEY"

Account

GET /v1/me alpha

The account behind the credential you just used, and what it can see. The first thing to call when two people share a deployment and you need to know whose data you are looking at.

Measured1ms. One indexed read.

Returns

{ org{id, name, created_at, members[{user_id, email, role, created_at}]}, usage{counts{agents, calls, documents, api_keys, phone_numbers, provider_accounts, contact_lists, campaigns, webhooks, boards}, call_seconds}, authenticated_as{kind, label, user_id, email, is_operator} }

Worth knowing
  • Every row in this deployment belongs to exactly one account. Most of that is enforced in the storage layer, where an unscoped query is refused outright rather than quietly returning everybody's data — but two list endpoints were found reaching past it and have been fixed, so the honest statement is that scoping is enforced per query and tested per endpoint, not guaranteed by construction.
  • authenticated_as.kind tells you what kind of credential answered: api_key for a managed key, user for a signed-in session, bootstrap for the server's own environment key, or call_token for an outbound call.
  • usage.counts is the quickest way to confirm you are looking at the account you think you are. A fresh account reads zero everywhere.

Example

curl https://api.latency.cam/v1/me -H "Authorization: Bearer $LATENCY_API_KEY"

GET /v1/auth/config alpha

What a sign-in page needs before it has any credential: whether accounts are enabled, and the public details to authenticate against. Open for that reason.

Returns

{ sign_in{enabled, url, publishable_key, algorithms[]}, api_keys, note }

Worth knowing
  • Only public values. The publishable key is designed to ship in a browser; nothing secret is here.
  • Your password is posted to the identity provider directly, never to us, so there is no path by which we could log or leak one.
  • Tokens are verified against the provider's published keys, and the ACCEPTED ALGORITHMS COME FROM OUR OWN CONFIG, never from the token's header. Trusting the header is the algorithm-confusion bug that turns a signature check into a formality.

Example

curl https://api.latency.cam/v1/auth/config

POST /v1/api-keys alpha

Create an API key for this account. The full key is returned once and only a hash is stored, so there is no endpoint that can read it back.

Parameters

NameTypeDefaultDescription
name reqstring—What it is for, e.g. "CRM integration". 1–120 characters.

Returns

{ id, name, prefix, key, created_at } — `key` appears in this response only.

Worth knowing
  • STORED AS A HASH. If the plaintext were kept, this endpoint would be a way to read every key on the account, and a stolen database would be a stolen account.
  • The prefix is kept so a key can be recognised in a log without the log containing the key.
  • Works immediately, including on the websocket. That was broken once and is now tested, because a key that authenticates over HTTP and not over the websocket is a key that appears to work until the first call.

Example

curl -X POST https://api.latency.cam/v1/api-keys \
  -H "Authorization: Bearer $LATENCY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "CRM integration"}'

GET /v1/api-keys alpha

Every key with its prefix, when it was last used and how often — never the key itself.

Parameters

NameTypeDefaultDescription
include_revokedbooleanfalseInclude revoked keys.

Returns

{ keys[{id, name, prefix, created_at, last_used_at, uses, revoked_at}], count }

Worth knowing
  • last_used_at and uses are what make an unused key safe to revoke. Without them, nobody dares.

Example

curl https://api.latency.cam/v1/api-keys -H "Authorization: Bearer $LATENCY_API_KEY"

DELETE /v1/api-keys/{key_id} alpha

Stops working immediately, on HTTP and on the websocket. The row survives so the audit trail does.

Parameters

NameTypeDefaultDescription
key_id reqstring (path)—The key id, not the key.

Returns

{ id, revoked_at }

Worth knowing
  • Immediate, and tested as immediate. A revocation that took effect on the next cache expiry would be the difference between revoking a key and intending to.

Errors

CodeWhenFix
404No such key on this accountCheck the list endpoint.

Example

curl -X DELETE https://api.latency.cam/v1/api-keys/ak_51c2 -H "Authorization: Bearer $LATENCY_API_KEY"

Service

GET /v1/models alpha

The full catalog: transcription with measured error rates, the 20 preset voices an agent can be given, the valid emotion tags, and the language counts. Voices for the nine speak-only languages are not listed here because an agent cannot use them — reach those through /v1/audio/speech.

Measured1ms.

Returns

{ stt[], tts[], lid[], _read_this_first }

Worth knowing
  • For STT entries, a "languages" list with one item means the model ONLY decodes that language.
  • realtime_suitable marks the one STT model fast enough for a live call.
  • The response is capability rather than catalog: the voices, the language counts and the model tiers. Which models implement them is not part of the contract, so it can change without breaking your integration.

Example

curl https://api.latency.cam/v1/models -H "Authorization: Bearer $LATENCY_API_KEY"

GET /health alpha

Whether the service is answering, and whether authentication is enforced. The one endpoint that needs no key, so a monitor can poll it. Send a key as well and it adds readiness, which is the part that tells you whether a call will actually connect.

Measured1ms.

Returns

{ ok, build, uptime_seconds, auth, gpu{}, models{} }

Worth knowing
  • After a cold start, models take about 40 seconds to warm. Until then requests still work but the first one is slow.
  • auth reports "OPEN - no API keys configured" if no keys are set. If you ever see that in production, fix it.

Example

curl https://api.latency.cam/health