Analytics and webhooks
What happened across every call, why the failures failed, and how to be told the moment one ends instead of polling for it.
Two measurement rules that change the numbers
Endpoints
| Path | Gives |
|---|---|
GET /v1/analytics/summary | Headline numbers, a daily trend, latency percentiles |
GET /v1/analytics/breakdown?by= | Grouped by language, ended_by, agent_id or agent_version |
GET /v1/analytics/search?q= | Full text across every transcript |
POST /v1/analytics/analyse | Reads the failures and groups them by root cause |
POST /v1/analytics/reindex | Rebuild the search index |
GET /v1/calls | The log. Transcripts omitted from the list |
GET /v1/calls/{id} | One call in full |
GET /v1/calls/{id}/recording | The audio, if recording was on |
POST /v1/boards | Save a set of filters to come back to |
GET /v1/boards | List them |
DELETE /v1/boards/{id} | Remove one. No call data is touched |
by= is whitelisted rather than interpolated. It arrives from a query parameter, and putting a caller-supplied string into SQL is how a reporting endpoint becomes an injection.
Boards: the numbers you check every morning
A board is a question about your calls, saved. The analytics endpoints are for finding something out once — set a range, read the answer, move on. A board is for a question you have decided to keep asking, and the reason it earns its own concept is mundane: if the same filters have to be re-entered every morning, then after a week nobody bothers and the number stops being watched.
curl -X POST https://api.latency.cam/v1/boards \
-H "Authorization: Bearer $LATENCY_KEY" -H "Content-Type: application/json" \
-d '{"name": "Tamil calls this week",
"filters": {"agent_id": "ag_7f3a2b", "widgets": ["language", "agent_version"]}}'filters object is free-form and stored as given, which means the dashboard decides what a board contains without that being a schema change on our side — adding a widget type is a change in one file, not a migration.The widget worth setting up first is agent_version. It answers whether the edit you made last week helped, which is the one question a version history on its own cannot answer.
Search works in Indic scripts
Full-text search over every transcript, ranked, with the matching words shown in context. The index is built with diacritic stripping turned off, and that is the whole reason it works: in Devanagari and Tamil the vowel marks are the word. Strip them from ऑर्डर and you have a string nobody would ever search for.
curl -G https://api.latency.cam/v1/analytics/search \ -H "Authorization: Bearer $LATENCY_KEY" \ --data-urlencode "q=ऑर्डर"
The failure analyser
A chart can tell you 38% of calls missed their goal. It cannot tell you that most of them are people asking about a refund window the agent was never given — which is the sentence that leads to a fix, and no grouping over the columns we store can produce it, because the reason lives in the words.
curl -X POST https://api.latency.cam/v1/analytics/analyse \
-H "Authorization: Bearer $LATENCY_KEY" -H "Content-Type: application/json" \
-d '{"sample": 20}'It reads a bounded sample of recent failed calls and returns at most five themes, each with the call ids it came from and something to change. The response states how many calls it read, and that the themes are a model's reading of transcripts rather than a measurement — because they are.
What every call records
Beyond the transcript: median and 95th-percentile turn time, time to first audio, the split between transcribing, thinking and speaking, how many times the caller interrupted, how many turns were answered from documents, and every language the call actually spoke.
Webhooks
Account-wide, signed, retried, and every attempt logged. Distinct from an agent's on_call_end_url, which is one URL, fired once, not retried and not recorded — the right shape for “post this to my CRM” and the wrong shape for an integration that must not miss a call.
| Event | When |
|---|---|
call.completed | A call ended, with transcript, extracted values and goal verdict |
call.goal_met / call.goal_missed | The verdict, separately, so you can route on it |
call.transferred | The agent asked for a handover to a person |
call.voicemail | The agent believes it reached a machine |
campaign.finished / campaign.failed | A campaign ended, or stopped itself |
Verifying a delivery came from us
import hmac, hashlib, time
def verify(secret, headers, body_bytes):
ts = headers['X-Latency-Timestamp']
if abs(time.time() - int(ts)) > 300:
return False # too old, refuse it
expected = hmac.new(secret.encode(), ts.encode() + b'.' + body_bytes,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers['X-Latency-Signature'])Retries, and when we give up
Five attempts over roughly twenty minutes — 10s, 30s, 2m, 10m, 20m. Long enough to ride out a deploy at your end, short enough that a delivery either arrives while it is still relevant or is visibly given up on. After 20 consecutive failures the webhook is switched off and says so: if your server has been down for a week you do not want a week of backlog when it returns, and we do not want a queue that never drains. Re-enable it from the dashboard.
Your URL is validated against private and link-local address space before it is stored, for the same reason tool URLs are — see tools.
/v1/calls/{id}/recording answers 307 to a signed link. Drop the Authorization header when you follow it — S3-style signing treats that header as overriding the signature in the URL, so forwarding it gets you an XML error where you expected audio. Browsers do not forward it across origins; curl -L and Python's urllib both do. Or pass ?redirect=false and take the bytes from us.