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.
wss://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
| Code | Meaning | Fix |
|---|---|---|
401 | Missing or bad key | Check the Authorization header, or ?api_key= on the websocket |
403 | Only POST /v1/voices, when consent_confirmed is not true | You need the speaker's permission. Licence requirement, not policy. |
422 | Bad request: unknown model or voice, unsupported language, empty or undecodable input | The message names the problem and usually lists the valid values |
429 | Never returned | There are no rate limits |
500 | A bug here | The response carries the exception type and message. Report it. |
501 | Nothing returns this now. Voice cloning is live at POST /v1/voices. | Use a preset voice, or clone your own with POST /v1/voices |
502 | The reply model could not be reached, or the tier is not enabled here | Use fast or balanced, which always are. The message says which of the two cases it is. |
503 | Something we depend on is temporarily unavailable | The message says which part, and whether retrying or a different parameter is the way round it. This is ours to fix, not yours. |
504 | Only /v1/translate, on a very large batch | Split the batch, or set num_beams: 1 |
1008 | Websocket close code for a rejected key | A 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.
Speech (12)
Agents (9)
Knowledge base (4)
Receiving calls (3)
Making calls (20)
GET/v1/provider-accounts/fieldsPOST/v1/provider-accountsGET/v1/provider-accountsDELETE/v1/provider-accounts/{account_id}POST/v1/contact-listsGET/v1/contact-listsGET/v1/contact-lists/{list_id}DELETE/v1/contact-lists/{list_id}POST/v1/dncGET/v1/dncDELETE/v1/dnc/{number}POST/v1/campaignsGET/v1/campaignsGET/v1/campaigns/{campaign_id}GET/v1/campaigns/{campaign_id}/callsPOST/v1/campaigns/{campaign_id}/startPOST/v1/campaigns/{campaign_id}/pausePOST/v1/campaigns/{campaign_id}/cancelPOST/v1/calls/outboundPUT/v1/provider-accounts/{account_id}/numbers
Analytics (8)
Webhooks (7)
Account (5)
Service (2)
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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
api_key req | string (query) | — | Your API key, in the URL: wss://api.latency.cam/ws/stream?api_key=sk-lat-... |
language | string | hi | Caller language, e.g. hi, ta, te, bn, en-IN. Sent in the configure frame. |
script | string | roman | Script for TTS input. Hindi sounds best from Roman text ("aapka order"); other languages use native. One of: roman native |
voice | string | — | Exact voice name, e.g. "Tamil (Male)". Omit for the default female voice of the language. |
llm | string | the fast tier | fast, balanced or precise. See /docs/models. |
stt_model | string | — | Omit this. The default identifies the caller's language itself and is the fastest option; there is no reason to change it. |
allowed_languages | string[] | — | 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_prompt | string | — | Your agent instructions. Ask for replies under about 15 words; long replies sound robotic on a call. |
audio_format | string | mulaw_8000 | mulaw_8000 is what telephony gives you. Applies to audio in BOTH directions. One of: mulaw_8000 pcm_16000 |
silence_ms | integer | 500 | Silence that ends a caller turn. Lower feels snappier but clips people who pause mid-sentence. |
min_speech_ms | integer | 200 | Ignore blips shorter than this, so a cough does not open a turn. |
interrupt_min_words | integer | 3 | Roughly 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_fillers | boolean | true | Play a cached acknowledgement the moment a turn ends. This is what removes the perceived wait. Leave it on. |
auto_language_detect | boolean | true | Follow 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. |
greeting | boolean | true | Play a cached greeting on connect. |
normalize_numbers | boolean | true | Rewrite digits as spoken words before synthesis. Leave it on or "15250" gets read wrong. |
lock_voice | boolean | true | Keep 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_turn | boolean | true | Let 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_transcripts | boolean | true | Emit 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_ms | integer | 400 | How 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_ms | integer | 1000 | How 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_tool | boolean | true | Let 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. |
pronunciations | object | 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.
- 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
| Code | When | Fix |
|---|---|---|
1008 | Bad or missing api_key | Pass ?api_key=sk-lat-... in the websocket URL. The server sends a JSON error frame explaining this before closing. |
0 | error 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
provider req | string (path) | — | Which carrier is calling this webhook. It decides the XML dialect in the response. One of: twilio plivo exotel |
To | string (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. |
From | string (form) | — | The caller. Recorded on the call row so you can match it to a CRM contact. |
CallSid | string (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.
- 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
| Name | Type | Default | Description |
|---|---|---|---|
provider req | string (path) | — | Which framing to speak. One of: twilio plivo exotel |
token req | string (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.
- 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
input req | string | — | Text to speak. Keep replies under ~15 words on a phone call. |
language req | string | — | Language code, e.g. hi, ta, te, bn, en. See GET /v1/models. |
script | string | native | For Hindi, Roman input ("aapka order aa gaya") is noticeably better than Devanagari. Other languages use native script. One of: roman native |
voice | string | — | Exact voice string from GET /v1/models, e.g. "Telugu (Male)". Omit and the female voice for that language is used. |
model | string | — | Omit it. There is one synthesiser and it is chosen for you. |
emotion | string | — | Emotion tag. Also settable inline by putting the tag at the END of input. |
format | string | pcm_24000 | mulaw_8000 for telephony — the downsampling is done here. One of: pcm_16000 pcm_24000 mulaw_8000 wav |
stream | boolean | false | Chunked audio. Required for live calls. |
temperature | number | 0.6 | Lower is more consistent. 0.4 is good for fixed phrases. |
normalize | boolean | true | Rewrite 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.
- 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
| Code | When | Fix |
|---|---|---|
422 | input is empty, or the voice/language is unknown | Check the voice string exactly matches one from GET /v1/models, including capitalisation and the bracketed gender. |
503 | The TTS model or the synthesis server is not up | Check 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.rawPOST /v1/audio/transcriptions alpha
Transcribe caller audio. Accepts wav, mp3, flac, ogg and raw mulaw or PCM. Optionally detects the language first.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
file req | file | — | Audio file. Multipart form upload. |
language | string | — | 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). |
model | string | — | Omit it. The default identifies the language itself and is the fastest. |
sample_rate | integer | — | Required only for raw headerless audio. |
detect_language | boolean | false | Run 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 }
- 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
| Code | When | Fix |
|---|---|---|
422 | Empty file, undecodable audio, or audio shorter than 10ms | Check the upload actually contains audio. For raw PCM or mulaw, pass sample_rate. |
503 | A non-default model was requested and could not be loaded | Omit 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
file req | file | — | Audio. 2 seconds or more is much more reliable than 1. |
top_k | integer | 3 | How many candidates to return. |
restrict_to | string | — | JSON array or comma list of candidate codes. This is the single biggest accuracy lever — narrow it whenever you can. |
sample_rate | integer | — | Required for raw headerless audio. |
Returns
{ candidates: [{ language, confidence }], duration_s, warning? }
- 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
| Code | When | Fix |
|---|---|---|
422 | Empty or undecodable file | Check the upload. |
503 | LID model unavailable | Check 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
text req | string | — | Text containing numerals. |
language | string | hi | Target language. |
style | string | spoken | spoken for quantities ("pandrah hazaar"), digits for account numbers read one by one, grouped for phone numbers read in pairs. One of: spoken digits grouped |
script | string | native | Output script. One of: roman native |
Returns
{ text, coverage, replacements[] }
- 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.
Returns
{ fillers[], categories, languages, _how_to_use }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
file req | file | — | 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 req | string | — | A label so you can recognise it later. |
consent_confirmed req | boolean | — | Must be true. You must have the speaker's explicit permission. The time you confirmed is recorded. |
language | string | — | 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 } }
- 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
| Code | When | Fix |
|---|---|---|
403 | consent_confirmed is not true | You need the speaker's permission. This is a licence requirement, not a policy choice. |
422 | The file is silent, too short, or has no recognisable speech | The message says which. Record a couple of clear sentences in a quiet room. |
503 | The cloning model is not installed on this deployment | Preset 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 }
- `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
| Name | Type | Default | Description |
|---|---|---|---|
text | string | — | What to say. A natural opening line in the chosen language if omitted. |
language | string | hi | Any of the two-way languages. |
phone_line | boolean | true | Set 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.wavDELETE /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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
language req | string (path) | — | e.g. hi, ta, te. |
category | string | ack | ack, greeting, thinking, or closing. |
format | string | mulaw_8000 | mulaw_8000 goes straight to a phone line. One of: wav pcm_16000 mulaw_8000 |
Returns
Raw audio bytes in the requested format.
- 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
filler_id req | string (path) | — | From GET /v1/fillers. |
format | string | mulaw_8000 | wav, 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
| Name | Type | Default | Description |
|---|---|---|---|
language | string | — | Just one language. Omit to build all 19. |
force | boolean | false | Re-synthesise fillers that already exist. Without this, existing files are left alone. |
Returns
{ generated[], skipped[], seconds }
- 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
text req | string | string[] | — | One string, or an array to translate as a batch. Batching is much faster per item. |
source_language req | string | — | e.g. en, hi, ta, te. See GET /v1/translate/languages. |
target_language req | string | — | e.g. hi, ta, te, en. |
num_beams | integer | 5 | 1 is about twice as fast with slightly worse output. |
backend | string | standard | standard 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_model | string | the fast tier | Only 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 }
- 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
| Code | When | Fix |
|---|---|---|
422 | Unsupported language code, or source equals target | Use a code from GET /v1/translate/languages. |
503 | The translation sidecar is not running | sudo systemctl restart it2-translate on the box, or retry with backend="llm". |
504 | A very large batch exceeded the timeout | Split 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
model | string | the fast tier | the fast tier is the fastest and the right default for calls. One of: the fast tier the balanced tier the precise tier |
messages req | array | — | Standard OpenAI message list. system, user and assistant roles. |
max_tokens | integer | 150 | Keep it low. Long replies sound robotic on a call. |
temperature | number | 0.7 | Standard. |
Returns
An OpenAI chat.completion object, plus latency_ms.
- 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
| Code | When | Fix |
|---|---|---|
422 | Unknown model, or no user message | The error lists the available model ids. |
502 | the reply model provider rejected the call | The 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
| Name | Type | Default | Description |
|---|---|---|---|
name req | string | — | 1–120 characters. |
config | object | — | Any of the 51 settings from GET /v1/config-schema. An unknown key is a 422 that names the key. |
note | string | — | Why you made this version. Shows up in the version history. |
publish | boolean | true | Whether this version answers calls immediately. false stages it as a draft. |
Returns
{ id, name, version, published_version, config, created_at }
- 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
| Code | When | Fix |
|---|---|---|
422 | Unknown or wrongly typed setting in config | The 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
| Name | Type | Default | Description |
|---|---|---|---|
include_archived | boolean | false | Include 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
| Name | Type | Default | Description |
|---|---|---|---|
agent_id req | string (path) | — | The agent id. |
version | integer | — | A specific version. Omit for the latest. |
Returns
{ id, name, version, published_version, config, archived, created_at, updated_at }
Errors
| Code | When | Fix |
|---|---|---|
404 | No such agent, or no such version | GET /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
| Name | Type | Default | Description |
|---|---|---|---|
agent_id req | string (path) | — | The agent id. |
config req | object | — | The COMPLETE configuration. It replaces, it does not merge. |
note | string | — | What changed and why. |
publish | boolean | true | Make this version live. |
Returns
{ id, version, published_version, config }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such agent | Check the id. |
422 | Unknown or wrongly typed setting | The 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
| Name | Type | Default | Description |
|---|---|---|---|
agent_id req | string (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
| Name | Type | Default | Description |
|---|---|---|---|
agent_id req | string (path) | — | The agent id. |
version req | integer | — | Which version to make live. 1 or greater. |
Returns
{ id, published_version }
- Calls already in progress keep the version they started on, so publishing during traffic is safe.
Errors
| Code | When | Fix |
|---|---|---|
404 | No such agent or version | Check /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
| Name | Type | Default | Description |
|---|---|---|---|
agent_id req | string (path) | — | The agent id. |
restore | boolean | false | true 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.
Returns
{ settings[{name, type, default, group, note, enum?}], count, groups[] }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
brief req | string | — | What the agent is for, in a sentence or two. 10–4000 characters. |
language | string | — | The language callers speak, e.g. hi, ta. Worth passing — it is the one thing a brief usually leaves implicit. |
model | string | the fast tier | Which model writes it. |
Returns
{ config, ignored[], suggested_name, model, note, draftable_fields[] }
- 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
| Code | When | Fix |
|---|---|---|
502 | The model was unreachable, or returned something that is not a config | Nothing 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
agent_id req | string (path) | — | Which agent gets this document. |
file req | file | — | PDF, DOCX, TXT, MD, CSV or HTML. |
Returns
{ doc_id, filename, chunks, characters }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such agent | Create the agent first. |
422 | Unsupported 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
| Name | Type | Default | Description |
|---|---|---|---|
agent_id req | string (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
| Name | Type | Default | Description |
|---|---|---|---|
agent_id req | string (path) | — | The agent id. |
doc_id req | string (path) | — | From the list endpoint. |
Returns
{ deleted, chunks_removed }
Errors
| Code | When | Fix |
|---|---|---|
404 | No such document on this agent | Check 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
agent_id req | string (path) | — | The agent id. |
query req | string | — | What the caller asked, in any language. |
top_k | integer | 3 | How many chunks to return. 1–10. |
Returns
{ results[{text, score, filename, doc_id}], count }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
agent_id | string | — | Only calls for one agent. |
limit | integer | 50 | How many rows. |
offset | integer | 0 | For paging. |
Returns
{ calls[], count }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
call_id req | string (path) | — | The call id. |
Returns
{ id, agent_id, agent_version, from, to, duration_s, languages[], turns[], variables{}, goal{}, recording_url?, timings{} }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such call on your account | Check 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
| Name | Type | Default | Description |
|---|---|---|---|
call_id req | string (path) | — | The call id. |
redirect | boolean | true | true 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.
- 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
| Code | When | Fix |
|---|---|---|
404 | No such call, or the agent was not recording | Set 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
| Name | Type | Default | Description |
|---|---|---|---|
number req | string | — | In E.164, e.g. +14155550100. 4–32 characters. |
agent_id req | string | — | Who answers. |
provider | string | — | Which carrier owns the number. Used to pick the XML dialect if the webhook does not say. One of: twilio plivo exotel |
label | string | — | Your name for it, e.g. "Main support line". |
Returns
{ number, agent_id, provider, label }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such agent | Create 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
| Name | Type | Default | Description |
|---|---|---|---|
number req | string (path) | — | URL-encode the plus: %2B14155550100. |
Returns
{ deleted }
Errors
| Code | When | Fix |
|---|---|---|
404 | That number is not routed | Check 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 }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
provider req | string | — | Which carrier. One of: twilio plivo exotel |
from_number req | string | — | The number outbound calls display. Must be one the carrier lets you originate from. |
credentials req | object | — | The fields from GET /v1/provider-accounts/fields, e.g. {"account_sid": "AC...", "auth_token": "..."}. |
label | string | — | Your name for this account. |
Returns
{ id, provider, from_number, label, fingerprints{} }
- 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
| Code | When | Fix |
|---|---|---|
422 | Unknown provider, or a required credential field missing | The message names the missing field. GET /v1/provider-accounts/fields is the list. |
503 | Encryption is not configured on the box | Rather 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
| Name | Type | Default | Description |
|---|---|---|---|
account_id req | string (path) | — | The account id. |
Returns
{ deleted }
Errors
| Code | When | Fix |
|---|---|---|
404 | No such account | Check 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
| Name | Type | Default | Description |
|---|---|---|---|
name req | string | — | 1–120 characters. |
contacts | array | — | e.g. [{"number": "+919876543210", "name": "Asim", "order_id": "A-1"}]. Everything other than the number becomes a variable the prompt can use. |
csv | string | — | Raw CSV text with a header row. Send this or contacts, not both. |
Returns
{ id, name, contacts, skipped[], variables_found[] }
- 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
| Code | When | Fix |
|---|---|---|
422 | No rows, or no recognisable number column | The 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
| Name | Type | Default | Description |
|---|---|---|---|
list_id req | string (path) | — | The list id. |
Returns
{ id, name, contacts[{number, variables{}}], count }
Errors
| Code | When | Fix |
|---|---|---|
404 | No such list | Check 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
| Name | Type | Default | Description |
|---|---|---|---|
list_id req | string (path) | — | The list id. |
Returns
{ deleted }
Errors
| Code | When | Fix |
|---|---|---|
404 | No such list | Check 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
| Name | Type | Default | Description |
|---|---|---|---|
numbers req | string[] | — | One or more numbers. At least one. |
reason | string | — | Why, e.g. "asked not to be called". Worth recording — a regulator asks. |
Returns
{ added, numbers[] }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
number req | string (path) | — | URL-encode the plus: %2B919876543210. |
Returns
{ deleted }
Errors
| Code | When | Fix |
|---|---|---|
404 | Not on the list | Check 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
name req | string | — | 1–120 characters. |
agent_id req | string | — | Who makes the calls. |
list_id req | string | — | Who to call. |
account_id req | string | — | Which carrier account to dial with. |
agent_version | integer | — | Pin a version for the whole campaign. Omit to use the published one at creation time. |
concurrency | integer | 3 | Simultaneous calls. 1–6. |
attempts_max | integer | 2 | Tries per contact, including the first. 1–5. |
retry_after_s | integer | 1800 | Wait before retrying a no-answer. 60–86400. |
window_start | string | 09:00 | Earliest local time to call. |
window_end | string | 21:00 | Latest local time to call. |
timezone | string | Asia/Kolkata | Which clock the window is on. |
ring_timeout_s | integer | 30 | Give up on an unanswered ring after this. 10–120. |
start | boolean | false | Begin immediately. Default is to create it paused so you can check it first. |
Returns
{ id, name, status, contacts, concurrency, window, created_at }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such agent, list or provider account | The message says which one. |
422 | Concurrency above 6, or a malformed window or timezone | The 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
| Name | Type | Default | Description |
|---|---|---|---|
campaign_id req | string (path) | — | The campaign id. |
Returns
{ id, name, status, agent_id, agent_version, contacts, attempted, in_flight, completed, goal_met, window{}, holding_because? }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such campaign | Check 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
| Name | Type | Default | Description |
|---|---|---|---|
campaign_id req | string (path) | — | The campaign id. |
status | string | — | Filter, e.g. completed, no_answer, failed, pending. |
limit | integer | 200 | How 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
| Name | Type | Default | Description |
|---|---|---|---|
campaign_id req | string (path) | — | The campaign id. |
Returns
{ id, status }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such campaign | Check the id. |
409 | Already finished or cancelled | Create 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
| Name | Type | Default | Description |
|---|---|---|---|
campaign_id req | string (path) | — | The campaign id. |
Returns
{ id, status, in_flight }
- in_flight tells you how many calls are still running, so you know when the campaign is truly quiet.
Errors
| Code | When | Fix |
|---|---|---|
404 | No such campaign | Check 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
| Name | Type | Default | Description |
|---|---|---|---|
campaign_id req | string (path) | — | The campaign id. |
Returns
{ id, status, dropped }
Errors
| Code | When | Fix |
|---|---|---|
404 | No such campaign | Check 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
| Name | Type | Default | Description |
|---|---|---|---|
to_number req | string | — | Who to ring. E.164 or a bare Indian ten-digit number. |
agent_id req | string | — | Which agent talks. |
account_id req | string | — | Which carrier account to dial through. |
agent_version | integer | — | Pin a version. Omit for the published one. |
variables | object | — | 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_s | integer | 30 | Give 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 }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such agent, or no such provider account | The message says which one. |
409 | The number is on your do-not-call list | Deliberate. Remove it with DELETE /v1/dnc/{number} only if you have a record of consent. |
422 | The carrier rejected the call permanently — unverified caller ID, bad credential, invalid number | The message carries the provider's own words. |
502 | The carrier failed in a way worth retrying | Try again. The message includes their error. |
503 | PUBLIC_BASE_URL is not set on the server | Without 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.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
account_id req | string (path) | — | The provider account. |
numbers req | string[] | — | 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.
- 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
| Code | When | Fix |
|---|---|---|
404 | No such account | Check GET /v1/provider-accounts. |
422 | An empty list | An 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
| Name | Type | Default | Description |
|---|---|---|---|
since | string | — | ISO date. Omit for all time. |
until | string | — | ISO date. |
agent_id | string | — | One agent only. |
Returns
{ summary{}, daily[], latency{ttfa_ms, turn_ms, stt_ms, llm_ms, tts_ms, totals, sampled}, note }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
by | string | language | What to group on. One of: language ended_by agent_id agent_version |
since | string | — | ISO date. |
until | string | — | ISO date. |
agent_id | string | — | Narrow to one agent first. |
Returns
{ by, rows[{value, calls, avg_seconds, goal_met, goal_scored, goal_met_rate}], count }
- 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
| Code | When | Fix |
|---|---|---|
422 | An unsupported `by` value | The message lists the four that work. |
Example
curl "https://api.latency.cam/v1/analytics/breakdown?by=agent_version" \ -H "Authorization: Bearer $LATENCY_API_KEY"
GET /v1/analytics/search alpha
Full-text search over transcripts, ranked, with the match shown in context. Indic scripts are searchable, which is not free: the tokeniser keeps combining marks, without which Devanagari and Tamil words are mangled into strings nobody would ever search for.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
q req | string | — | What to look for, in any script. |
agent_id | string | — | One agent only. |
limit | integer | 50 | How many hits. |
Returns
{ query, hits[{call_id, excerpt, started_at, language, goal_achieved}], count }
- This is the fastest way to answer "did anyone complain about X". Reading call logs one at a time does not scale past a few dozen.
- If results look thin after a bulk import, run POST /v1/analytics/reindex.
Example
curl "https://api.latency.cam/v1/analytics/search?q=refund" \ -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 }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
since | string | — | ISO date. |
until | string | — | ISO date. |
agent_id | string | — | One agent only. |
sample | integer | 20 | How many failed calls to read. 3–60. |
Returns
{ themes[{theme, why, fix, call_ids[], count}], sampled, model }
- 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
| Code | When | Fix |
|---|---|---|
502 | The model was unreachable | The 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
| Name | Type | Default | Description |
|---|---|---|---|
name req | string | — | What this board answers, e.g. "Tamil calls this week". 1–120 characters. |
filters | object | — | Whatever you want to come back to. The dashboard stores {since, until, agent_id, widgets[]}. |
Returns
{ id, name, filters, created_at }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
board_id req | string (path) | — | The board id. |
Returns
{ deleted }
Errors
| Code | When | Fix |
|---|---|---|
404 | No such board | Check 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} }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
url req | string | — | Where to POST. HTTPS, publicly reachable. |
events | string[] | ["*"] | Which events, or ["*"] for all. Names from GET /v1/webhooks/events. |
description | string | — | Your note about what this feeds. |
Returns
{ id, url, events[], secret, active, created_at } — the secret appears in this response only.
- 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
| Code | When | Fix |
|---|---|---|
422 | A malformed URL, a private or internal address, or an unknown event name | The 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 }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
hook_id req | string (path) | — | The webhook id. |
Returns
{ deleted }
Errors
| Code | When | Fix |
|---|---|---|
404 | No such webhook | Check 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
| Name | Type | Default | Description |
|---|---|---|---|
hook_id req | string (path) | — | The webhook id. |
active | boolean | true | false to switch one off deliberately. |
Returns
{ id, active }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such webhook | Check 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
| Name | Type | Default | Description |
|---|---|---|---|
webhook_id | string | — | One webhook only. |
limit | integer | 100 | How many rows. |
Returns
{ deliveries[{id, webhook_id, event, status, attempts, error, next_try_at, created_at}], count }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
hook_id req | string (path) | — | The webhook id. |
Returns
202. The result appears in GET /v1/webhooks/deliveries.
- 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
| Code | When | Fix |
|---|---|---|
404 | No such webhook | Check 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.
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} }
- 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 }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
name req | string | — | What it is for, e.g. "CRM integration". 1–120 characters. |
Returns
{ id, name, prefix, key, created_at } — `key` appears in this response only.
- 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
| Name | Type | Default | Description |
|---|---|---|---|
include_revoked | boolean | false | Include revoked keys. |
Returns
{ keys[{id, name, prefix, created_at, last_used_at, uses, revoked_at}], count }
- 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
| Name | Type | Default | Description |
|---|---|---|---|
key_id req | string (path) | — | The key id, not the key. |
Returns
{ id, revoked_at }
- 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
| Code | When | Fix |
|---|---|---|
404 | No such key on this account | Check 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.
Returns
{ stt[], tts[], lid[], _read_this_first }
- 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.
Returns
{ ok, build, uptime_seconds, auth, gpu{}, models{} }
- 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