Webhook events reference

Telenow delivers events to the endpoints you register as signed HTTP POSTs. This page is the complete contract: delivery format, signature verification, retry and endpoint-health rules, and every event with its exact payload. For a working receiver, see Receive & verify webhooks; to register an endpoint, see the Webhook management API.

Session events fire for every conversation — AI phone calls, manual / softphone calls, browser web calls, the public widget, Chat API sessions, and WhatsApp/Instagram DM conversations — so one receiver can ingest them all.

Event catalog

Session-scoped — these carry sessionId, agentId and identifier. The one exception is recording.ready: a recording stored without a session carries sessionId: null, agentId: null and no identifier key at all.

EventFires when
call.startedmedia/conversation is live (not on dial or ring)
call.endedthe conversation finishes; optionally enriched with recording + transcript
call.completedany call is over, whatever the outcome — answered, unanswered, busy, failed, refused, or closed by the platform. Exactly one per call
call.machine_detectedanswering-machine detection classified the answer as a machine
call.dtmfthe caller pressed a keypad digit — one event per digit
transcript.readyeach finalized transcript turn during the conversation
tool.invokedthe agent calls a configured tool/function
recording.readya recording is finalized and stored (may land after the call)
call.analyzedpost-call analysis completes

Org-scoped — no session, so no agentId and no identifier:

EventFires when
whatsapp.message.receivedan inbound WhatsApp message is persisted
whatsapp.message.statusMeta reports a sent message's delivery status
whatsapp.account.healthMeta reports a WABA account/quality/review change
billing.invoice_issuedan invoice is raised for the billing period
billing.payment_overduean issued invoice passed its due date unpaid
billing.account_suspendedthe org is suspended after the unpaid grace period
billing.number_renewal_duea number's renewal is approaching, with any credit shortfall
billing.number_reneweda number's rent was charged and its term extended
billing.number_renewal_failedrenewal could not be charged — credits short
billing.number_release_pendinga number is scheduled for release; still recoverable
billing.number_released_nonpaymentthe number was released and the DID given up

Subscribable by name since 2026-09-12. These eight were previously emitted and delivered, but POST /api/v1/hooks rejected the names as typos — so they could only reach an endpoint subscribed with ["*"]. They are now first-class: name any of them in events. If you subscribed with ["*"] to catch them, nothing changes.

The four that report a failure — payment_overdue, account_suspended, number_renewal_failed, number_released_nonpayment — are the ones worth wiring to an alert. number_released_nonpayment is terminal: the DID is gone and may be re-issued to another customer.

Delivery format

Each event is an HTTP POST with a JSON body:

HeaderValue
Content-Typeapplication/json
X-VoiceAI-Eventthe event type, e.g. call.ended
X-VoiceAI-Deliverydelivery id — stable across retries, different per endpoint
X-VoiceAI-Signaturesha256=<hex> — HMAC‑SHA256 of the raw body
  • Respond 2xx. You have 30 seconds. Anything else — including 3xx, since redirects are not followed — is a failure and is retried. Point the endpoint at its final HTTPS URL.
  • ⚠️ Never return 410 Gone for an event you simply don't handle. 410 is the one non-2xx status that is not retried: it permanently disables the endpoint, killing every future event for every subscription on it. Return 200 and ignore what you don't want.
  • Delivery is at-least-once, and duplicates are routine — not just a crash artifact. A delivery is leased for 60 seconds while it is sent, so a worker that crashes after POSTing, or a transient database error while recording the result, re-sends the same body with the same X-VoiceAI-Delivery. De-duplicate on that header and make processing idempotent.
  • One call's events arrive in order. Telenow sends an endpoint the events of one call one after another, in the order the call raised them: call.started, then that call's transcript.ready and tool.invoked events, then call.ended and call.analyzed. The call's next event waits until the previous one is delivered. While your endpoint is down — a timeout, a connection error, a 408, 429 or 5xx — the call's later events wait for that one's retries (at most until they run out, ≈21 minutes). If your endpoint answers an event with any other 4xx, it has refused that event, and the call moves on at once: the refused event is retried on its own and can arrive after the call's later ones. Events of different calls, and events outside any call (WhatsApp, billing), are sent in parallel, so they can arrive in any order relative to each other; order on occurredAt, and tolerate out-of-order arrival.
  • Events go out as soon as they happen, several at a time. A new event is usually sent within a fraction of a second. Each endpoint can receive several requests at once: Telenow starts at 8 at a time, sends more while your endpoint answers quickly (up to 64), and halves the number — at most once every 2 seconds — when it slows down: a reply that takes 5 seconds or more, a timeout, a connection error, a 408, 429 or 5xx; down to 1. After 3 failures in a row (a slow success is not one; requests that fail together count once) it pauses sending to that endpoint, from 2 seconds up to a minute. These limits are per Telenow server. When several calls are waiting, other calls' call.started and call.ended go ahead of per-utterance events. Each endpoint has its own limit, so a slow endpoint holds up mainly its own events. If your endpoint needs a higher ceiling, contact support.

Signature verification

Compute HMAC‑SHA256 over the raw request body bytes using the endpoint's signing_secret (returned once, at creation), hex-encode, and compare constant-time against the hex portion of X-VoiceAI-Signature.

Verify against the raw bytes — not a re-serialized object, which changes the JSON and breaks the comparison. Full Node and Python examples are in the integration guide.

There is no timestamp or nonce in the signature, so a captured delivery replays forever and the signature alone does not defeat replay. Reject deliveries whose occurredAt is outside your own window, and de-duplicate on X-VoiceAI-Delivery. Note that legitimate retries are replays of the same signed body.

Endpoint URL rules

Endpoint URLs must be https:// and must not resolve into a blocked range: loopback, private, link-local (including the cloud-metadata address 169.254.169.254), multicast, broadcast, unspecified, 0.0.0.0/8, CGNAT 100.64/10, benchmarking 198.18/15, IETF protocol assignments 192.0.0.0/24, the TEST-NET ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24), 240/4, and the IPv6 equivalents — ::1, fc00::/7, fe80::/10, multicast, unspecified, the NAT64 prefix 64:ff9b::/96, and v4-mapped forms such as ::ffff:169.254.169.254.

This check is re-run on every delivery attempt, not just at registration. A URL that stops validating — including a host whose DNS moves into a blocked range — has its delivery marked dead with no retries, and the endpoint is disabled, with everything still queued for it. A host that fails to resolve is different: that is retried like any connection error, so a DNS outage never switches an endpoint off. A URL that passed is not looked up again for a minute; every resolved IP is re-checked at connect time, so DNS rebinding cannot get past it.

Retries and endpoint health

A failed delivery retries with exponential backoff over 8 total attempts:

After attempt12345678
Next retry in10s20s40s80s160s320s640s—

The last attempt lands ≈21 minutes after the first; the delivery is then marked dead.

Exhausting retries does not disable your endpoint. It marks that one delivery dead and records the reason in the endpoint's last_error so you can see it in the dashboard. Later events keep being delivered; only the same call's later events wait while it is retried (see ordering above).

An endpoint is disabled in exactly two cases:

  1. it replies 410 Gone — the REST-hooks convention for "this subscription is gone". The delivery is marked dead immediately, with no further retries, and so is everything still queued for the endpoint.
  2. its URL fails re-validation at send time (see above).

Endpoints are immutable, and disabling is one-way. There is no update route — to change the url, events, agentId or the enrichment flags you delete and re-create, and the replacement gets a new signing secret. There is no re-enable route either: an endpoint disabled by a 410 or by a failed URL re-validation must be re-created.

Plan a rollover window where your receiver accepts both secrets. While both endpoints are live every event is delivered twice — once per endpoint, each copy with its own X-VoiceAI-Delivery — so de-duplicating on that header will not collapse the pair; de-duplicate on sessionId + event + occurredAt for the duration.

Note the role split on the management API: POST accepts owner, admin or developer, but DELETE requires owner or admin — a developer can create the replacement but not retire the old endpoint.

Inspecting deliveries

GET /api/orgs/{orgId}/webhooks/{id}/deliveries returns one row per delivery, not per attempt — the row is updated in place, so it carries the attempt counter and only the latest attempt's status and response. status is one of pending, failed, delivered, dead. limit is clamped to 1–200 (default 50).

Up to 4 KiB of your response body is stored and is readable by every member of the org — don't echo secrets in your webhook response.

Rows are reaped after APP_RETENTION_DAYS (default 14 days), but that window is not always bounded: only delivered and dead rows are eligible, nothing is reaped while the org is under a legal hold, and setting APP_RETENTION_DAYS=0 disables the reaper entirely. A pending/failed row stranded by a disabled endpoint is never retried and never deleted.

Fields on every event

FieldPresent onMeaning
orgIdevery eventThe organization the event belongs to.
occurredAtevery eventWhen we raised the event — RFC 3339, UTC, exactly 3 fractional digits. Not when the delivery arrived.
agentIdsession-scoped eventsThe agent. Absent on the org-scoped whatsapp.* / billing.* events. On a recording.ready whose recording has no agent the key is present and null — test the value, not key presence.
identifiersession-scoped eventsYour correlation key. Present but null when the call had none. Absent entirely on org-scoped events, and on a session-scoped event whose own sessionId is null (only recording.ready can be).
subAccountIdevents of a sub-accountThe sub-account that raised it (equal to its orgId). Absent on every event of an organization that is not a sub-account.

A sub-account's events also reach its parent. Every event a sub-account raises is delivered to the sub-account's own endpoints and to each of its parent's org-wide endpoints (agentId: null) whose events match — one endpoint on the parent sees every customer's calls, told apart by subAccountId. The parent's copy is listed in the parent's delivery log. A parent's agent-scoped endpoints never receive a sub-account's events.

Money alerts about a sub-account's numbers go to the parent only. Its numbers rent on the parent's wallet, so the billing.number_* events for them are raised by the parent (carrying the sub-account's subAccountId) and delivered to the parent's endpoints — the sub-account, which has no wallet to top up, does not receive them.

identifier is the value you passed when you started the call — on /initiate-call (queued or not), /init-web-call, or the Chat API. Match it against your own record instead of keeping a sessionId ledger. On WhatsApp it is the contact's phone number; on Instagram it is the participant's IGSID.

identifier: null is not proof the call had none. The lookup fails soft: a transient database error, or a sessionId whose row has already been removed, produces the same null. Treat null as "unknown", not as "absent".

occurredAt is deliberately not called timestamp — whatsapp.message.status already publishes a timestamp meaning Meta's status time.

Values shared across call events

  • direction — one of inbound, outbound, web, whatsapp, instagram. Softphone legs are always outbound, including an inbound PSTN call that rings an operator's dashboard.
  • channel — the finer classification: telephony, softphone, web_chat, chat, whatsapp, instagram, simulation. Browser web calls report web_chat — webhooks cannot distinguish a browser voice call from a text chat; use the call log for that split. There is no web_call value.
  • callSid — the carrier's id for the leg, the only way to line an event up against a Twilio/Plivo callback. null on web_chat, chat, simulation, whatsapp and instagram. It is non-null on channel: "softphone" — an operator softphone leg is a real PSTN call.
  • direction, channel, callSid, fromNumber and toNumber all come from one read of the session row. If that read fails, all five degrade to null together rather than the event being dropped. answeredBy is not in that group on call.machine_detected, where it is passed straight into the emitter and stays populated.

Events

call.started

Fires once, when the media/conversation path goes live.

{
  "event": "call.started",
  "sessionId": "…",
  "agentId": "…",
  "orgId": "…",
  "userId": "…",
  "identifier": "acct_42",
  "occurredAt": "2026-06-08T12:00:00.000Z",
  "direction": "inbound",
  "channel": "telephony",
  "callSid": "CA…",
  "fromNumber": "+14155550123",
  "toNumber": "+14155550100",
  "from": "+14155550100",
  "variables": { "customer_name": "Alex", "plan": "Pro" },
  "startTime": "2026-06-08T12:00:00Z"
}
  • It does not fire for a call that never connected. Unanswered, busy and failed outbound attempts open no media stream — read those from the calls API or campaign results.
  • It does not re-fire when a no-answer transfer reconnects a fresh carrier stream into the same session, nor on an agent-to-agent handoff.
  • Agent simulations emit neither call.started nor call.ended — teardown skips the emit for a dry run — so channel: "simulation" never appears on either, and a wildcard subscriber sees a simulation's transcript.ready / tool.invoked turns with no lifecycle bracket at all.
  • fromNumber is the caller and toNumber the called party, always in that order whatever the direction. from is a legacy, direction-ambiguous field — it is the agent's own configured number, which is the dialed party outbound and your own DID inbound. Prefer fromNumber/toNumber.
  • userId is an attribution field — the platform user or API-key owner the session bills to. It is never the person on the phone.
  • variables carries the call's context variables. It is rarely null on an agent-backed call: the 11 built-in date/time variables (current_date, current_datetime, today_date, current_weekday, …) are seeded at session creation, so expect at least those keys even when you supplied none. A call placed through /initiate-call with an identifier also carries variables.identifier.

call.ended

Fires when the conversation finishes.

{
  "event": "call.ended",
  "sessionId": "…",
  "agentId": "…",
  "orgId": "…",
  "userId": "…",
  "identifier": "acct_42",
  "occurredAt": "2026-06-08T12:02:22.000Z",
  "durationSecs": 142,
  "startTime": "2026-06-08T12:00:00Z",
  "endTime": "2026-06-08T12:02:22Z",
  "messageCount": 18,
  "direction": "inbound",
  "channel": "telephony",
  "callSid": "CA…",
  "fromNumber": "+14155550123",
  "toNumber": "+14155550100",
  "answeredBy": "human",
  "endReason": null,
  "fromOrTo": "+14155550100",
  "variables": { "customer_name": "Alex", "plan": "Pro" },

  "recording": { "id": "…", "url": "https://… (signed)", "expiresAt": "2026-06-08T13:00:00Z" },
  "transcript": [
    { "role": "user", "text": "Hi, I need to reschedule.", "at": "2026-06-08T12:00:05Z" },
    { "role": "assistant", "text": "Sure — what date works?", "at": "2026-06-08T12:00:08Z" }
  ]
}
  • ⚠️ Unlike call.started, this event does fire for a call that never connected. A dial that was refused, unanswered, busy or failed still tears its half-built session down through the same teardown path, so you can receive a call.ended with no preceding call.started. Never pair the two events to detect a stuck call — key on call.ended alone.
  • Agent simulations emit neither event, so a simulation never appears here at all.
  • startTime + durationSecs ≈ endTime. durationSecs is whole seconds, truncated, while the two instants are full precision, so the identity is approximate. Unlike occurredAt, startTime and endTime are not fixed-precision — they carry 0, 3, 6 or 9 fractional digits depending on the value, so parse them as RFC 3339 rather than with a fixed format string. durationSecs is raw wall-clock between session start and teardown — it is not the billed duration; reconcile billing against the calls API.
  • answeredBy is whatever last landed on the session row, and it is not a closed set. Carrier verdicts are human, machine_start, machine_end_beep, machine_end_silence, machine_end_other or a bare machine; carriers also send fax and unknown, and any non-empty value is persisted. null when detection never ran, and also when the verdict lands after this payload was built.
  • ⚠️ A carrier machine* verdict is not passed through unchanged by default. It is rewritten to human when a live person is heard during the post-verdict grace window, and to amd_unconfirmed — a value no carrier sends — when that window expires with nothing corroborating the carrier's claim. Both behaviours are on unless explicitly disabled, so do not detect voicemail by testing for a machine prefix alone; pair it with call.machine_detected, which fires only once a verdict is actually acted on.
  • endReason is why the call stopped, and it is an open string, not a closed enum. Values the platform writes include agent_end_call, llm_end_call (the model called end_call), flow_end (a deterministic-flow terminal node), silence_hangup, max_duration, amd_hangup, ivr_hangup and voicemail_drop. null when the call simply hung up. Match defensively.
  • fromOrTo is the same legacy field as call.started's from.

Enrichment. recording and transcript appear only when the endpoint was created with includeRecording / includeTranscript and the data exists at call end:

  • transcript is built from the last 500 stored rows of any role, then filtered to user and assistant — so a call that also stored system rows (keypad notes, transfer markers) delivers fewer than 500 turns. Each text is truncated at 8,000 bytes of UTF-8, not characters: a Hindi or Tamil turn is cut at roughly 2,600 characters. An enriched body on a long call can approach several megabytes — raise your receiver's body limit, or subscribe without it and fetch the transcript from the API.
  • recording here is a snapshot at teardown. For recordings that finalize slightly later, use recording.ready.

call.completed

Fires once per call, when the call is over — whatever happened. Answered, no answer, busy, failed, a dial the carrier refused before it rang, an answering machine, a call the platform had to close itself: each produces exactly one call.completed. It covers direct dials, queued /initiate-call dials and campaign dials, and inbound and browser calls too. This is the event to key a call record on: unlike call.started and call.ended, it never depends on the call having connected.

{
  "event": "call.completed",
  "sessionId": "…",
  "agentId": "…",
  "orgId": "…",
  "identifier": "acct_42",
  "occurredAt": "2026-06-08T12:00:41.000Z",
  "outcome": "no-answer",
  "disposition": "no-answer",
  "status": "ended",
  "endReason": null,
  "answeredBy": null,
  "direction": "outbound",
  "channel": "telephony",
  "callSid": "CA…",
  "fromNumber": "+14155550100",
  "toNumber": "+14155550123",
  "durationSecs": 0,
  "billableSecs": null,
  "startTime": "2026-06-08T12:00:00Z",
  "endTime": "2026-06-08T12:00:31Z",
  "variables": { "customer_name": "Alex" },
  "analysis": null,
  "campaignId": "…",
  "campaignTargetId": "…",
  "attempt": 1
}
  • Exactly once. The event is queued in the same database transaction that records it as sent, so a restart or a second server never produces a second one. Delivery retries resend the same delivery (same X-VoiceAI-Delivery), never a new event.
  • When it fires. As soon as the call is final — within a few seconds. For an answered phone call the carrier's final status report trails the hang-up by a few seconds; the event waits for it (at most about a minute) so disposition and billableSecs are filled in. If that report never comes, the event goes out without it (disposition: null).
  • outcome is the carrier's verdict and is never null: answered, no-answer, busy or failed. A dial the carrier refused before it rang (no callSid) is failed. A call without a carrier — a browser call, a chat — is answered. disposition is the raw value it is read from, null when no carrier reported one.
  • An answering machine is outcome: "answered" with answeredBy starting with machine (see call.machine_detected for how far to trust that verdict).
  • status is ended, or orphaned when the platform closed a call whose server stopped reporting it (endReason: "reaped" or "boot_recovery").
  • billableSecs is the seconds the carrier billed, summed over the call's phone legs; null for a browser call and whenever no carrier reported a duration. durationSecs is the session's wall-clock length — they differ, and neither is the charge. The settled cost comes from GET /api/orgs/:orgId/calls/:id/cost.
  • Campaign dials carry campaignId, campaignTargetId (the target's id) and attempt (1 for the first dial of that target, counting retries); all three are null on any other call. A queued /initiate-call is a campaign dial too: its campaignId is the one the 202 response returned. Each attempt is its own call, so a target dialled three times produces three events.
  • analysis is present only if post-call analysis had already finished; normally it has not. Subscribe to call.analyzed for it. callRecord appears when the agent graded the call.
  • direction, channel, callSid, fromNumber, toNumber, answeredBy and endReason mean exactly what they mean on call.ended. There is no from/to/fromOrTo here — use fromNumber/toNumber.
  • A sub-account's call.completed reaches its parent's org-wide endpoints too, stamped subAccountId, like every event.
  • Agent simulations produce none. Only calls that finish after 2026-10-17 produce one; there is no back-fill of older calls.

call.machine_detected

Fires when answering-machine detection classifies the answer as a machine.

{
  "event": "call.machine_detected",
  "sessionId": "…",
  "agentId": "…",
  "orgId": "…",
  "identifier": "acct_42",
  "occurredAt": "2026-06-08T12:00:04.000Z",
  "answeredBy": "machine_start",
  "action": "voicemail_drop",
  "callContinues": false,
  "direction": "outbound",
  "channel": "telephony",
  "callSid": "CA…",
  "fromNumber": "+14155550100",
  "toNumber": "+14155550123",
  "detectedAt": "2026-06-08T12:00:04Z"
}
  • answeredBy is the raw verdict: machine_start, machine_end_beep, machine_end_silence, machine_end_other, or a bare machine on carriers that don't sub-classify. One further value, machine_text_detect, is ours rather than the carrier's — our transcript backstop recognised a voicemail greeting the carrier missed.
  • action is what we did: ivr_hangup, call_screen, amd_hangup, voicemail_drop.
  • callContinues is true only for call_screen, which speaks its line and lets the conversation carry on. The other three end the call.
  • detectedAt is when detection fired; occurredAt is when the event was raised. They are close but not identical.

call.dtmf

Fires once per keypad digit.

{
  "event": "call.dtmf",
  "sessionId": "…",
  "agentId": "…",
  "orgId": "…",
  "identifier": "acct_42",
  "occurredAt": "2026-06-08T12:00:31.000Z",
  "digit": "1"
}
  • One event per valid DTMF symbol — 0–9, #, *, A–D (uppercased). Non-DTMF payloads, and presses the carrier reports after the session is torn down, are not delivered.
  • It is emitted before the keypad opt-out and the agent's dtmf_enabled branch, so it reports on manual/softphone calls too — those run a stub agent with no keypad settings and would otherwise report nothing. That is what makes it usable for driving a programmable call over the API: dial, play a recording, and collect keypresses here.

⚠️ call.dtmf is not PII-redacted. It reports the captured digit verbatim, unlike the platform's internal DTMF log which records only menu selections. If callers key card or account numbers, treat this event as cardholder data — or don't subscribe to it.

transcript.ready

Fires per finalized transcript turn.

{
  "event": "transcript.ready",
  "sessionId": "…",
  "agentId": "…",
  "orgId": "…",
  "identifier": "acct_42",
  "occurredAt": "2026-06-08T12:00:14.000Z",
  "role": "user",
  "text": "I'd like to check my order status."
}
  • role is user or assistant.
  • text is scrubbed with the session's PII policy before emission. On a keypad-enabled node a user turn can fold digits into the text: "my number is [Keypad input: 4821]".
  • agentId is re-read live at every emit, so on an agent-to-agent handoff it changes mid-conversation. An endpoint scoped to agent A silently stops receiving this call's turns after a handoff to agent B. Use an org-wide endpoint if you need the whole conversation.
  • Agent simulations emit transcript.ready and tool.invoked, so a wildcard subscriber can see turn events with no call.started bracket.

tool.invoked

Fires when the agent calls a configured tool or function.

{
  "event": "tool.invoked",
  "sessionId": "…",
  "agentId": "…",
  "orgId": "…",
  "identifier": "acct_42",
  "occurredAt": "2026-06-08T12:00:41.000Z",
  "name": "lookup_order",
  "arguments": { "orderId": "A-1234" },
  "result": { "status": "shipped" },
  "status": "success",
  "latencyMs": 320
}
  • status is one of "success", "error", "not_found" (the model named a tool not bound at this step), "abandoned" (the caller barged in and a read-only lookup was dropped), or a confirmation_* value when a confirmation gate is in play. There is no "ok". A non-2xx from an HTTP tool becomes "error" with result set to {"error": "…"}.
  • arguments is not always an object. When the model's argument text fails to parse, the raw string is passed through, so arguments can be a JSON string — and it can be null. Parse defensively.
  • For connector and built-in tools, arguments are the model's raw arguments; author-configured bindings are not applied to the emitted copy.
  • result is capped at 1 MiB (1,048,576 bytes); an over-cap response becomes {"error": "tool response exceeded 1 MB cap"} with status: "error" — an app-provided tool reports {"error": "app tool response exceeded 1 MB cap"}.
  • Deterministic-flow terminal nodes do not emit this event. A flow graph's end, transfer and agent nodes hang up, bridge and hand off without a tool.invoked.
  • agentId names the agent that owned the tool call, which after a transfer differs from the agent the session now points at.
  • arguments and result are PII-scrubbed. The masks are not a fixed set — an org can author its own rules with its own mask text.

recording.ready

Fires when a recording is finalized and stored, including recordings that land after the call ends.

{
  "event": "recording.ready",
  "recordingId": "…",
  "sessionId": "…",
  "agentId": "…",
  "orgId": "…",
  "identifier": "acct_42",
  "occurredAt": "2026-06-08T12:02:30.000Z",
  "durationSecs": 142,
  "recording": { "id": "…", "url": "https://… (signed)", "expiresAt": "2026-06-08T13:00:00Z" }
}
  • Signed URLs expire after 1 hour — on the S3 storage backend. A local-filesystem deployment returns a plain, unsigned and non-expiring route URL while still stamping expiresAt an hour out, so treat the URL as long-lived and secret there. Download promptly or re-sign through the recordings API.
  • recording degrades to { "id": "…" } — no url, no expiresAt — when the URL cannot be signed. Always guard on url before using it.
  • sessionId, agentId and durationSecs are each nullable: a recording uploaded without a session (or with an unparseable session id) carries null. agentId here is present and null — not absent. identifier is the key that goes missing entirely when sessionId is null.

call.analyzed

Fires when post-call analysis completes — shortly after call.ended, and only when analysis is enabled for the agent.

{
  "event": "call.analyzed",
  "sessionId": "…",
  "agentId": "…",
  "orgId": "…",
  "identifier": "acct_42",
  "occurredAt": "2026-06-08T12:02:48.000Z",
  "piiCollected": [],
  "analysis": {
    "summary": "Customer asked about an order and the agent confirmed it shipped.",
    "sentiment": "positive",
    "sentimentScore": 0.8,
    "disposition": "resolved",
    "actionItems": ["Email the tracking link to the customer"],
    "customData": { "order_id": "A-1234", "wants_callback": false, "refund_amount": null },
    "evidence": { "sentiment": "great, thanks for checking", "order_id": "it's order A one two three four" },
    "qa": [{ "key": "verified_identity", "met": true, "evidence": "can you confirm your postcode?" }],
    "objections": [],
    "score": 92,
    "coaching": [],
    "hallucinations": [],
    "cx": { "rating": null, "friction": [], "highlights": [] },
    "topics": ["order status"],
    "keywords": ["order", "shipping"],
    "talkRatio": { "agentWords": 120, "customerWords": 80, "agentTurns": 9, "customerTurns": 9 },
    "model": "…"
  }
}
  • sentiment is exactly "positive", "neutral", "negative" or null; sentimentScore is clamped to [-1, 1]; score is the LLM-judge grade clamped to [0, 100] — not a rating out of 10.
  • All 17 analysis keys are always present. Sections for checks you have not configured, or have switched off under What the analysis includes, arrive empty ({} / [] / null), never absent — test for emptiness, not for key presence. summary, sentiment, sentimentScore, disposition and score can be null.
  • customData includes per-call fields. A call placed with its own custom fields (per-call settings) delivers them here, beside the agent's.
  • model is the model that produced the analysis, as provider/model. When the agent's chosen analysis model failed on the call, the platform model analysed it instead, and model names the platform model.
  • The stored analysis you read over REST has the same results in a slightly different shape (the talk-ratio counts are flat there). See API responses → The call.analyzed webhook has a different shape.
  • It fires at most once per call: a session that already has an analysis row is never re-analyzed. It never fires for an empty transcript, a ring-out, an analysis error, or a call older than the 2-hour lookback.

customData — your own extracted fields

This is where the custom values you configure in post-call analysis are delivered. Each field you define on the agent under postCallAnalysis.customFields — see Post-call analysis for how to set them up — becomes one property of customData:

"customFields": [
  { "key": "order_id",       "label": "Order ID",       "type": "string",  "description": "The order number the customer refers to" },
  { "key": "wants_callback", "label": "Wants callback", "type": "boolean", "description": "Did the customer ask to be called back?" },
  { "key": "refund_amount",  "label": "Refund amount",  "type": "number",  "description": "Refund figure agreed, if any" }
]
  • Keyed by key, never by label. label is a display name for the dashboard; your webhook receiver should read customData.order_id.
  • A field the transcript does not answer should arrive as null — the extraction prompt tells the model to return null rather than guess. Treat that as an instruction, not a shape guarantee: nothing reconciles the returned object against your configured keys, so a key can be missing entirely and a key you never configured can appear. Check for presence before reading.
  • type is a hint, not a contract. It is passed to the extraction model as text (string, number, boolean or date; anything else is stored as string) and the returned value is not coerced or validated. A field typed number can still arrive as a string. Validate on your side.
  • Up to 30 custom fields per agent, enforced when the agent is saved — not when the webhook is built. Extra fields are silently truncated (the save still succeeds), and the 30 are counted before blank-key entries are dropped, so a blank-key entry still consumes a slot.
  • description is the only text the model reads. label is not a usable fallback: saving the agent always writes a description key (an empty string when you leave it blank), and the prompt falls back to label only when that key is absent or null — which the save path never produces. A field with a blank description reaches the model as a bare key with no guidance. Always write a description.

evidence is keyed by the same names. For every non-empty judgment and extracted field, the model adds a verbatim transcript quote under that key — so evidence.order_id is the sentence the value of customData.order_id came from, and evidence.sentiment backs the sentiment call. Use it to show a human why a value was extracted, or to reject an extraction whose quote does not support it. A field the model could not evidence simply has no key here.

A custom field configured with a piiRule also captures its value into the encrypted PII vault, where it is reported in piiCollected (fieldKey set to that field's key) and retrieved through the audited, owner-only reveal. It is not removed from the extraction: the key still appears in customData, but its value is whatever the model could read off the already-masked transcript — normally the rule's mask string ("[REDACTED:CARD]") or null. Never read the captured value from customData, and do not assume the key is absent.

qa — your QA rubric

Criteria you configure under postCallAnalysis.qaCriteria ([{key, description}], same 30-per-agent cap applied at save) are asked for as one array entry each, shaped {"key": …, "met": true|false, "evidence": "<verbatim quote or empty string>"}. Only the array itself is validated — individual entries are the model's output passed through, so treat the per-entry shape as expected rather than guaranteed. qa is [] when no rubric is configured.

piiCollected

An array describing tier-2 PII the call captured: {id, ruleSlug, fieldKey, source, expiresAt, collected}. It is presence only — never the value and never its ciphertext. ruleSlug is always set. fieldKey is nullable: it names the custom field that asked for the value, and is null when the rule collected on its own (a workspace- or platform-level collect with no piiRule field behind it) — the key is always present, so branch on the value, not on key presence. collected is always true; it marks the row as a capture and is not a status to test. The value itself is retrieved through the audited, owner-only reveal.

customData, evidence and qa are scrubbed with the policy the call recorded — or, when it recorded none, with the platform default. That default is not "no redaction": card redaction is on out of the box (PII_REDACT_PAN defaults on), so an org that has configured nothing still gets card numbers masked here. Scrubbing reaches string values only, so a digit sequence the model returned as a JSON number is not masked.

whatsapp.message.received

An inbound WhatsApp message was persisted. Org-scoped — no agentId, no identifier.

Meta Cloud planes only. whatsapp.message.received and whatsapp.message.status are emitted for managed and bring-your-own Meta Cloud channels. Channels on other WhatsApp providers persist messages without emitting these events.

{
  "event": "whatsapp.message.received",
  "orgId": "…",
  "occurredAt": "2026-06-08T12:00:00.000Z",
  "channelId": "…",
  "channelPhone": "+14155550100",
  "threadId": "…",
  "messageId": "…",
  "wamid": "wamid.…",
  "from": "+919876543210",
  "contactName": "Alex",
  "type": "text",
  "body": "Hi, is my order shipped?",
  "content": null,
  "media": null,
  "timestamp": "2026-06-08T12:00:00Z",
  "raw": { "from": "919876543210", "id": "wamid.…", "type": "text", "text": { "body": "Hi, is my order shipped?" } }
}
  • channelPhone is always null on the bring-your-own (meta_whatsapp) plane — only the managed plane stores a channel phone.
  • from is normalised to +<digits>; an input carrying no digits at all is passed through trimmed and unchanged.
  • media is always present as a key — null for a text message. A media message's event is deferred until the bytes have been pulled from Meta. raw is Meta's original message object, verbatim.
  • body and content are both nullable and their population depends on type — media, location, contacts and unhandled types each leave one or both null.
  • Media signedUrl values expire after 1 hour on the S3 backend only; a local-filesystem deployment returns a plain non-expiring route URL.
  • De-duplication is keyed on a derived id, not on wamid — a message with no provider id gets a synthetic hash key.

whatsapp.message.status

Meta reported a delivery status for a message you sent. Org-scoped.

{
  "event": "whatsapp.message.status",
  "orgId": "…",
  "occurredAt": "2026-06-08T12:00:05.000Z",
  "channelId": "…",
  "channelPhone": "+14155550100",
  "wamid": "wamid.…",
  "status": "delivered",
  "recipient": "+919876543210",
  "timestamp": "2026-06-08T12:00:05Z",
  "errors": null,
  "pricing": { "category": "utility", "billable": true, "type": "regular" },
  "conversation": { "id": "…", "origin": { "type": "utility" } }
}

timestamp is Meta's status time. occurredAt is our own clock, when we raised the event. They are different instants; neither substitutes for the other.

  • pricing and conversation are Meta's own objects, passed through — pricing is what you reconcile WhatsApp spend against.
  • Status only ever advances — a non-advancing status (a sent arriving after read) is silently dropped rather than delivered.
  • A status update whose id or status is missing, or is not a JSON string, is skipped.

whatsapp.account.health

Meta reported a WABA account, quality or review change. Org-scoped.

{
  "event": "whatsapp.account.health",
  "orgId": "…",
  "occurredAt": "2026-06-08T12:00:00.000Z",
  "wabaId": "123456789012345",
  "field": "phone_number_quality_update",
  "value": { "display_phone_number": "+14155550100", "event": "FLAGGED", "current_limit": "TIER_1K" }
}
  • field is Meta's raw webhook field name and value is Meta's payload copied verbatim — its shape varies by field, and value.health_status in particular can be either a string or an object. Treat value as an opaque Meta document and refresh the live health endpoint for the authoritative banner state.
  • Duplicates are expected and are not de-duplicated. The event can also be silently dropped before delivery, so do not rely on it alone to detect a token revoke — poll account health as well.

billing.*

Eight org-scoped billing and number-lifecycle events. All carry event, orgId and occurredAt, and none carry sessionId, agentId or identifier — an endpoint with agentId set never receives them.

Invoices. Both share one shape.

{
  "event": "billing.invoice_issued",
  "orgId": "…",
  "occurredAt": "2026-06-08T12:00:00.000Z",
  "invoiceId": "00000000-0000-4000-8000-00000000000a",
  "periodStart": "2026-01-01",
  "periodEnd": "2026-01-31",
  "totalBilled": "128.40",
  "currency": "USD",
  "dueOn": "the 2nd"
}
  • billing.payment_overdue is the same payload, raised when an issued invoice passes its due date unpaid.
  • totalBilled is a decimal string, not a number — it is money, and a float would round it. Parse it as a decimal.
  • dueOn is human prose ("the 2nd"), not a date. Do not parse it.

Suspension.

{
  "event": "billing.account_suspended",
  "orgId": "…",
  "occurredAt": "2026-06-08T12:00:00.000Z",
  "reason": "unpaid_invoice",
  "graceDays": 7
}

Number lifecycle. Four events, keyed on the number rather than the invoice.

{
  "event": "billing.number_renewal_due",
  "orgId": "…",
  "occurredAt": "2026-06-08T12:00:00.000Z",
  "e164": "+14155550100",
  "renewsAt": "2026-02-01T04:30:00Z",
  "rentUsd": "1.50",
  "shortfallUsd": null,
  "atRisk": false
}
  • shortfallUsd is null when the balance covers the renewal and a decimal string when it does not; atRisk is the boolean form of the same fact. Test atRisk, not shortfallUsd != null.
  • renewsAt is an RFC 3339 instant in UTC. The companion email states the date in the org's local timezone, so the two can name different calendar days.
{ "event": "billing.number_renewed",          "e164": "+14155550100", "rentUsd": "1.50", "period": "2026-01-01" }
{ "event": "billing.number_renewal_failed",   "e164": "+14155550100", "rentUsd": "1.50", "graceDays": 7 }
{ "event": "billing.number_release_pending",  "e164": "+14155550100", "reason": "postpaid_suspended_pending", "afterSuspendDays": 30 }
  • period on number_renewed is the renewal anniversary date (YYYY-MM-DD) — not the YYYY-MM month key used on the purchase and proration paths.

⚠️ billing.number_released_nonpayment has TWO payload shapes under one name, discriminated by reason. Branch on reason; do not assume a fixed key set.

{ "reason": "renewal_unpaid",      "e164": "+14155550100", "overdueDays": 3,     "graceDays": 0 }
{ "reason": "postpaid_suspended",  "e164": "+14155550100", "daysSuspended": 45,  "afterSuspendDays": 30 }

This event is terminal: the DID has been given up and may be re-issued to another customer. It is not a warning — number_release_pending is the warning.

What is and is not redacted

SurfaceRedaction
transcript.ready textScrubbed with the session's PII policy
tool.invoked arguments / resultDeclared params blanked, then deep-scrubbed (string values only)
call.ended transcriptBuilt from already-masked stored messages
call.analyzed summary / actionItems / objections / coaching / hallucinations / cx.friction / cx.highlightsScrubbed with the call's policy, or the platform default (cards masked by default). topics, keywords, disposition and model are never scrubbed
call.analyzed customData / evidence / qaSame policy, string values only — a digit run returned as a JSON number is not masked
call.dtmf digitNot redacted — verbatim
variables (on call.started / call.ended)Not redacted — delivered verbatim, including values a flow extract node captured

Subscribing

Two registration surfaces, and they do not behave identically.

Management APIREST-hooks API
RoutePOST /api/orgs/{orgId}/webhooksPOST /api/v1/hooks
Body fieldscamelCase only — url, events, agentId, includeRecording, includeTranscriptdifferent names, either casing — target_url (also targetUrl/url), agent_id, include_recording, include_transcript
Validates event namesNo — any string is acceptedYes — 400 on a name outside the catalog

⚠️ On the management API, snake_case keys (include_recording) are silently ignored and the endpoint is created with the defaults. This fails without an error.

  • Use ["*"] to receive every event, or list exact names. events defaults to ["*"] on both surfaces — omitting the field, or sending an empty array, subscribes you to everything, including the unredacted call.dtmf digit.
  • An endpoint with agentId set receives only session events for that agent, and never receives the org-scoped whatsapp.* / billing.* events. Leave agentId unset for an org-wide endpoint.
  • The signing_secret is returned only once, at creation. It is never included in list responses.
  • GET /api/v1/events/sample?type=call.ended returns { "event_type": …, "is_real": true|false, "samples": [ … ] } — the payload is at samples[0], and is_real distinguishes your org's most recent real delivery from an obviously-fake canned sample. The query parameter is type (or event) — event_type returns 400.