Webhook events reference
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.
| Event | Fires when |
|---|---|
call.started | media/conversation is live (not on dial or ring) |
call.ended | the conversation finishes; optionally enriched with recording + transcript |
call.machine_detected | answering-machine detection classified the answer as a machine |
call.dtmf | the caller pressed a keypad digit — one event per digit |
transcript.ready | each finalized transcript turn during the conversation |
tool.invoked | the agent calls a configured tool/function |
recording.ready | a recording is finalized and stored (may land after the call) |
call.analyzed | post-call analysis completes |
Org-scoped — no session, so no agentId and no identifier:
| Event | Fires when |
|---|---|
whatsapp.message.received | an inbound WhatsApp message is persisted |
whatsapp.message.status | Meta reports a sent message's delivery status |
whatsapp.account.health | Meta reports a WABA account/quality/review change |
billing.*events also exist —billing.invoice_issued,billing.payment_overdue,billing.account_suspended,billing.number_renewal_due,billing.number_renewed,billing.number_renewal_failed,billing.number_release_pending,billing.number_released_nonpayment. They are emitted and delivered, butPOST /api/v1/hooksrejects the names, so you can only receive them by subscribing with["*"]or via the management API.
Delivery format
Each event is an HTTP POST with a JSON body:
| Header | Value |
|---|---|
Content-Type | application/json |
X-VoiceAI-Event | the event type, e.g. call.ended |
X-VoiceAI-Delivery | delivery id — stable across retries, different per endpoint |
X-VoiceAI-Signature | sha256=<hex> — HMAC‑SHA256 of the raw body |
- Respond
2xx. You have 30 seconds. Anything else — including3xx, since redirects are not followed — is a failure and is retried. Point the endpoint at its final HTTPS URL. - ⚠️ Never return
410 Gonefor an event you simply don't handle.410is the one non-2xx status that is not retried: it permanently disables the endpoint, killing every future event for every subscription on it. Return200and ignore what you don't want. - Delivery is at-least-once, and duplicates are routine — not just a crash artifact. Rows are leased for 60 seconds and then delivered sequentially, so a batch held up behind slow receivers, a worker that crashes after POSTing, and a transient database error while recording the result all re-send the same body with the same
X-VoiceAI-Delivery. De-duplicate on that header and make processing idempotent. - Ordering is not guaranteed. A failed delivery is re-queued behind later events, so
call.endedcan arrive before atranscript.readyfrom the same call. Order onoccurredAt, and tolerate out-of-order arrival. - Deliveries are picked up by a 5-second poller, so a first attempt lags the event by a few seconds when the queue is idle. Each poll claims up to 32 due deliveries from a queue shared across all endpoints and orgs, and sends them one at a time — so time spent waiting on slow receivers (up to 30 seconds each) is added to everything behind them. Treat delivery latency as best-effort.
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
occurredAtis outside your own window, and de-duplicate onX-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, or one that stops resolving — has its delivery marked dead with no retries, and the endpoint is disabled. 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 attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Next retry in | 10s | 20s | 40s | 80s | 160s | 320s | 640s | — |
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.
An endpoint is disabled in exactly two cases:
- it replies
410 Gone— the REST-hooks convention for "this subscription is gone". The delivery is markeddeadimmediately, with no further retries. - 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,agentIdor 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 a410or 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 onsessionId+event+occurredAtfor the duration.Note the role split on the management API:
POSTaccepts owner, admin or developer, butDELETErequires 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
| Field | Present on | Meaning |
|---|---|---|
orgId | every event | The organization the event belongs to. |
occurredAt | every event | When we raised the event — RFC 3339, UTC, exactly 3 fractional digits. Not when the delivery arrived. |
agentId | session-scoped events | The 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. |
identifier | session-scoped events | Your 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). |
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: nullis not proof the call had none. The lookup fails soft: a transient database error, or asessionIdwhose row has already been removed, produces the samenull. 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 ofinbound,outbound,web,whatsapp,instagram. Softphone legs are alwaysoutbound, 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 reportweb_chat— webhooks cannot distinguish a browser voice call from a text chat; use the call log for that split. There is noweb_callvalue.callSid— the carrier's id for the leg, the only way to line an event up against a Twilio/Plivo callback.nullonweb_chat,chat,simulation,whatsappandinstagram. It is non-null onchannel: "softphone"— an operator softphone leg is a real PSTN call.direction,channel,callSid,fromNumberandtoNumberall come from one read of the session row. If that read fails, all five degrade tonulltogether rather than the event being dropped.answeredByis not in that group oncall.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.startednorcall.ended— teardown skips the emit for a dry run — sochannel: "simulation"never appears on either, and a wildcard subscriber sees a simulation'stranscript.ready/tool.invokedturns with no lifecycle bracket at all. fromNumberis the caller andtoNumberthe called party, always in that order whatever the direction.fromis a legacy, direction-ambiguous field — it is the agent's own configured number, which is the dialed party outbound and your own DID inbound. PreferfromNumber/toNumber.userIdis an attribution field — the platform user or API-key owner the session bills to. It is never the person on the phone.variablescarries the call's context variables. It is rarelynullon 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-callwith anidentifieralso carriesvariables.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 acall.endedwith no precedingcall.started. Never pair the two events to detect a stuck call — key oncall.endedalone. - Agent simulations emit neither event, so a simulation never appears here at all.
startTime + durationSecs ≈ endTime.durationSecsis whole seconds, truncated, while the two instants are full precision, so the identity is approximate. UnlikeoccurredAt,startTimeandendTimeare 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.durationSecsis raw wall-clock between session start and teardown — it is not the billed duration; reconcile billing against the calls API.answeredByis whatever last landed on the session row, and it is not a closed set. Carrier verdicts arehuman,machine_start,machine_end_beep,machine_end_silence,machine_end_otheror a baremachine; carriers also sendfaxandunknown, and any non-empty value is persisted.nullwhen 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 tohumanwhen a live person is heard during the post-verdict grace window, and toamd_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 amachineprefix alone; pair it withcall.machine_detected, which fires only once a verdict is actually acted on. endReasonis why the call stopped, and it is an open string, not a closed enum. Values the platform writes includeagent_end_call,llm_end_call(the model calledend_call),flow_end(a deterministic-flow terminal node),silence_hangup,max_duration,amd_hangup,ivr_hangupandvoicemail_drop.nullwhen the call simply hung up. Match defensively.fromOrTois the same legacy field ascall.started'sfrom.
Enrichment. recording and transcript appear only when the endpoint was created with includeRecording / includeTranscript and the data exists at call end:
transcriptis built from the last 500 stored rows of any role, then filtered touserandassistant— so a call that also storedsystemrows (keypad notes, transfer markers) delivers fewer than 500 turns. Eachtextis 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.recordinghere is a snapshot at teardown. For recordings that finalize slightly later, userecording.ready.
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"
}
answeredByis the raw verdict:machine_start,machine_end_beep,machine_end_silence,machine_end_other, or a baremachineon 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.actionis what we did:ivr_hangup,call_screen,amd_hangup,voicemail_drop.callContinuesistrueonly forcall_screen, which speaks its line and lets the conversation carry on. The other three end the call.detectedAtis when detection fired;occurredAtis 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_enabledbranch, 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.dtmfis 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."
}
roleisuserorassistant.textis 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]".agentIdis 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.readyandtool.invoked, so a wildcard subscriber can see turn events with nocall.startedbracket.
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
}
statusis 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 aconfirmation_*value when a confirmation gate is in play. There is no"ok". A non-2xx from an HTTP tool becomes"error"withresultset to{"error": "…"}.argumentsis not always an object. When the model's argument text fails to parse, the raw string is passed through, soargumentscan be a JSON string — and it can benull. Parse defensively.- For connector and built-in tools,
argumentsare the model's raw arguments; author-configured bindings are not applied to the emitted copy. resultis capped at 1 MiB (1,048,576 bytes); an over-cap response becomes{"error": "tool response exceeded 1 MB cap"}withstatus: "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,transferandagentnodes hang up, bridge and hand off without atool.invoked. agentIdnames the agent that owned the tool call, which after a transfer differs from the agent the session now points at.argumentsandresultare 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
expiresAtan hour out, so treat the URL as long-lived and secret there. Download promptly or re-sign through the recordings API. recordingdegrades to{ "id": "…" }— nourl, noexpiresAt— when the URL cannot be signed. Always guard onurlbefore using it.sessionId,agentIdanddurationSecsare each nullable: a recording uploaded without a session (or with an unparseable session id) carriesnull.agentIdhere is present andnull— not absent.identifieris the key that goes missing entirely whensessionIdis 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": "…"
}
}
sentimentis exactly"positive","neutral","negative"ornull;sentimentScoreis clamped to[-1, 1];scoreis the LLM-judge grade clamped to[0, 100]— not a rating out of 10.- All 17
analysiskeys are always present. Sections for checks you have not configured arrive empty ({}/[]), never absent — test for emptiness, not for key presence.summary,sentiment,sentimentScore,dispositionandscorecan benull. - 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 bylabel.labelis a display name for the dashboard; your webhook receiver should readcustomData.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. typeis a hint, not a contract. It is passed to the extraction model as text (string,number,booleanordate; anything else is stored asstring) and the returned value is not coerced or validated. A field typednumbercan 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.
descriptionis the only text the model reads.labelis not a usable fallback: saving the agent always writes adescriptionkey (an empty string when you leave it blank), and the prompt falls back tolabelonly when that key is absent ornull— 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
piiRulealso captures its value into the encrypted PII vault, where it is reported inpiiCollected(fieldKeyset to that field'skey) and retrieved through the audited, owner-only reveal. It is not removed from the extraction: the key still appears incustomData, but its value is whatever the model could read off the already-masked transcript — normally the rule's mask string ("[REDACTED:CARD]") ornull. Never read the captured value fromcustomData, 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,evidenceandqaare 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_PANdefaults 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.receivedandwhatsapp.message.statusare 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?" } }
}
channelPhoneis alwaysnullon the bring-your-own (meta_whatsapp) plane — only the managed plane stores a channel phone.fromis normalised to+<digits>; an input carrying no digits at all is passed through trimmed and unchanged.mediais always present as a key —nullfor a text message. A media message's event is deferred until the bytes have been pulled from Meta.rawis Meta's original message object, verbatim.bodyandcontentare both nullable and their population depends ontype— media, location, contacts and unhandled types each leave one or bothnull.- Media
signedUrlvalues 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" } }
}
timestampis Meta's status time.occurredAtis our own clock, when we raised the event. They are different instants; neither substitutes for the other.
pricingandconversationare Meta's own objects, passed through —pricingis what you reconcile WhatsApp spend against.- Status only ever advances — a non-advancing status (a
sentarriving afterread) is silently dropped rather than delivered. - A status update whose
idorstatusis 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" }
}
fieldis Meta's raw webhook field name andvalueis Meta's payload copied verbatim — its shape varies by field, andvalue.health_statusin particular can be either a string or an object. Treatvalueas 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.
What is and is not redacted
| Surface | Redaction |
|---|---|
transcript.ready text | Scrubbed with the session's PII policy |
tool.invoked arguments / result | Declared params blanked, then deep-scrubbed (string values only) |
call.ended transcript | Built from already-masked stored messages |
call.analyzed summary / actionItems / objections / coaching / hallucinations / cx.friction / cx.highlights | Scrubbed 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 / qa | Same policy, string values only — a digit run returned as a JSON number is not masked |
call.dtmf digit | Not 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 API | REST-hooks API | |
|---|---|---|
| Route | POST /api/orgs/{orgId}/webhooks | POST /api/v1/hooks |
| Body fields | camelCase only — url, events, agentId, includeRecording, includeTranscript | different names, either casing — target_url (also targetUrl/url), agent_id, include_recording, include_transcript |
| Validates event names | No — any string is accepted | Yes — 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.eventsdefaults to["*"]on both surfaces — omitting the field, or sending an empty array, subscribes you to everything, including the unredactedcall.dtmfdigit. - An endpoint with
agentIdset receives only session events for that agent, and never receives the org-scopedwhatsapp.*/billing.*events. LeaveagentIdunset for an org-wide endpoint. - The
signing_secretis returned only once, at creation. It is never included in list responses. GET /api/v1/events/sample?type=call.endedreturns{ "event_type": …, "is_real": true|false, "samples": [ … ] }— the payload is atsamples[0], andis_realdistinguishes your org's most recent real delivery from an obviously-fake canned sample. The query parameter istype(orevent) —event_typereturns 400.
Related
- Webhook management API — register, list, and delete endpoints (both surfaces).
- Receive & verify webhooks — a secure receiver with de-duplication and async processing.
- Automation platforms — Zapier / n8n / Make / viaSocket.
- Post-call analysis — what drives
call.analyzed.