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.

EventFires when
call.startedmedia/conversation is live (not on dial or ring)
call.endedthe conversation finishes; optionally enriched with recording + transcript
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.* events also existbilling.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, but POST /api/v1/hooks rejects 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:

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. 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.ended can arrive before a transcript.ready from the same call. Order on occurredAt, 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 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, 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 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.

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.
  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).

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 timestampwhatsapp.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.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 — 09, #, *, AD (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 arrive empty ({} / []), never absent — test for emptiness, not for key presence. summary, sentiment, sentimentScore, disposition and score can be null.
  • 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.

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 onlyurl, events, agentId, includeRecording, includeTranscriptdifferent names, either casingtarget_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.