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

Success is counted over calls that have a goal, never over all callsOtherwise adding one agent without a goal makes your success rate fall, which reads as a regression that did not happen. Calls with no goal are reported separately, and “unclear” stays its own state rather than being folded into failure.
Latency is percentiles, not averagesA mean hides the tail, and the tail is what the caller experiences. An agent averaging 900ms per turn with one turn at six seconds feels broken, and the average says it was fine.

Endpoints

PathGives
GET /v1/analytics/summaryHeadline 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/analyseReads the failures and groups them by root cause
POST /v1/analytics/reindexRebuild the search index
GET /v1/callsThe log. Transcripts omitted from the list
GET /v1/calls/{id}One call in full
GET /v1/calls/{id}/recordingThe audio, if recording was on
POST /v1/boardsSave a set of filters to come back to
GET /v1/boardsList 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"]}}'
A board stores the question, never the answerOpening one re-reads the analytics endpoints live, so it cannot show you a stale snapshot from the day it was created. The 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.

These had to be added before analytics were possibleTurn timings were being sent to the client and thrown away, so “what is our median turn latency across ten thousand calls?” — the first question anyone running this asks — was unanswerable from storage. Analytics cannot be added later to data nobody kept.

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.

EventWhen
call.completedA call ended, with transcript, extracted values and goal verdict
call.goal_met / call.goal_missedThe verdict, separately, so you can route on it
call.transferredThe agent asked for a handover to a person
call.voicemailThe agent believes it reached a machine
campaign.finished / campaign.failedA 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'])
The timestamp is inside the signature, deliberatelySigning the body alone produces a signature that is valid forever, so anyone who captured a single delivery could replay it indefinitely and you would have no way to tell. With the timestamp signed too, an old delivery fails your own freshness check. Reject anything more than a few minutes old.

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.

Following the recording redirect programmaticallyWith object storage configured, /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.