Event reference
Every webhook event Nerva emits, with payload shape and timing.
This page lists every event Nerva can emit. For setup, scope, authentication, and delivery semantics, see the Webhooks overview.
Event lifecycle pattern
Most channels follow the same four-event lifecycle:
| Event | When | Payload |
|---|---|---|
*.started | Session opens | Identifier-only ping. Off by default. |
*.ended | Session closes | Immediate. Whatever data we have at hangup/timeout — analysis, cost, and recordingUrl may be null. |
*.analyzed | Post-processing complete | Full payload with analysis, cost, recording URL. |
*.failed | Terminal failure (call never connected, pipeline error) | No *.analyzed follows. |
Customers wanting an immediate CRM-status-update signal subscribe to
*.ended. Customers wanting full analytics data subscribe to *.analyzed.
Both is fine — you'll get two POSTs per session.
Voice (call.*)
Fired for voice agents — inbound telephony, outbound dials, and campaign calls.
Payload shape The voice payloads documented below are the finalized shape. Existing organizations are migrated on a per-org flag; new orgs get this shape by default. The shape mirrors the chat envelope: same outer wrapper, same
contactblock, sameusage/cost/analysisblocks. Channel-specific fields live in a dedicatedcallblock.Forward-compat: every entry in
messages[]carries atypefield. Today it is always"text". Future kinds (tool_call,tool_result,transfer,voicemail_drop) will reuse the same field — design your handler to switch onmessage.typeand ignore unknown values.
Standard envelope
All voice webhook deliveries share this outer shape (identical to chat):
Headers (unchanged from the Webhooks overview):
Content-Type: application/jsonX-Nerva-Signature: <hex sha256 hmac>whenwebhookSecretis setAuthorization: …per the integration'sauthconfig
Dedup: subscribers should dedupe on (event, data.id). We dedup
internally on (integrationId, event, data.id) so retries always deliver
the same body — your handler can be naturally idempotent.
Canonical data shape
Predictability is the contract. Every voice event ships these blocks
under data with the same field names in the same place. Events add
their own extras after these blocks but never reshape them — write one
parser for contact / call / recording / usage / cost and reuse
it across all four lifecycle events.
Field meanings:
id— voice call record id (cuid). The same value acrosscall.started→call.ended→call.analyzedfor one call. Use it as your idempotency key together withevent.agentId/agentName— the voice agent that handled the call;agentNameis denormalized so you can route without a second lookup.channel— always"voice"for this event family. The literal exists so a single handler can dispatch chat/voice/sms/whatsapp by channel.direction—"inbound"(caller dialed in) or"outbound"(we dialed out — manual or campaign).contact— the person record.contact.idis your CRM-style identifier; the rest is populated as we learn it. All fields arenull(not omitted) until known. Identical shape to the chatcontactblock.call— the telephony leg identifiers and routing. Equivalent of chat'svisitor+contextblocks, but for phone calls.call.campaignIdis non-null only for calls dialed by a campaign.metadata— customer-supplied passthrough key/value pairs from themetadatafield onPOST /v1/calls. Echoed verbatim on everycall.*event for the call, so you can correlate with your own records (CRM IDs, source tags, internal call IDs) without an extra lookup.nullwhen no metadata was supplied. Inbound calls always havemetadata: nullfor now.
Unknown fields within a block are null, never omitted. Your parser
can rely on the block existing on every event.
call.started
Fires the moment a call is created in our system — for inbound on
receipt of the carrier webhook, for outbound right before we dial. This
is a slim ping: it carries identifiers only. The full Call record
ships on call.ended / call.analyzed / call.failed. Off by default.
| When | Immediate, on call create |
|---|---|
| Default | Off |
call.ended
Fires the moment the call hangs up. Carries the full conversation
(messages[] inline, same shape as chat) but not the analysis (which
runs after — see call.analyzed). Cost figures are interim at this point
and may be "0" for calls where the post-call pipeline has not yet
finalized them. Default on.
| When | Immediate, on hangup |
|---|---|
| Default | On |
call.analyzed
Fires after the post-call pipeline finishes (cost calculation, recording
upload, LLM-driven analysis). Identical shape to call.ended plus
analysis populated, final cost figures, and a usable recording.url.
This is the self-contained final event — subscribe here if you want everything in a single POST without making a follow-up API call.
| When | After post-call pipeline completes (~5–60 s after call.ended) |
|---|---|
| Default | On |
Showing only the deltas from call.ended:
Notes on the analysis block:
- Identical schema to
chat.analyzed.analysis— one parser handles both channels. - Scores (
sentimentScore,resolutionScore,qualityScore) are 0–100 integers.nullwhen the analysis pipeline couldn't score (e.g. zero-duration calls). outcomeenum:"successful"|"unsuccessful"|"escalated"|"missed".customDatais the agent'sanalysisSchemaoutput — shape is per-agent and customer-controlled.- The block is
nulloncall.endedandcall.failed, populated oncall.analyzed. In degraded states — missing transcript, analyzer LLM outage —analysismay also benulloncall.analyzed. The webhook still fires so subscribers can record the call's other artifacts (recording, cost, transcript). Parse defensively.
Notes on recording.url:
- Long-lived signed URL on
app.nerva.ctplai.org. Re-delivered identically on webhook retries, so you can store it. recording.providerUrlis the raw Twilio/Plivo URL, included for customers who want to fetch directly from the carrier. Short-lived; treat as opaque.recording.offsetMsis the millisecond gap betweenstartedAtand the moment recording actually began (carrier setup delay). Subtract from per-messagestartMsif you need to re-align transcript to audio.
call.failed
Fires when a call errors or never connects (carrier dial failure,
pipeline crash, watchdog marking a stuck in-progress call as failed).
No call.analyzed follows. Same shape as call.ended plus an
error block; cost may be "0" and analysis is always null.
| When | On terminal failure |
|---|---|
| Default | On |
Vocabulary reference
status: "completed" | "failed" | "missed". (in-progress is
an internal value that never appears in webhook payloads.)
endReason (only on call.ended / call.failed):
| Value | Meaning |
|---|---|
hangup | Either side hung up normally (set by the telephony webhook handler) |
disconnect | Pipeline-side end with no more specific reason — the common case for normally-ended calls |
pipeline_error | Internal pipeline crash before completion |
watchdog_timeout | Stuck in-progress call swept by the watchdog after 1 hour |
null on call.started.
The enum is non-exhaustive — treat it as a forward-compatible string and
switch on known values, defaulting unknown values to "ended for some other
reason" rather than rejecting. New reasons may be added as carriers expose
more granular dial outcomes (e.g. busy, no_answer, voicemail for
campaign dials).
message.type: always "text" today. Future values:
"tool_call" / "tool_result" / "transfer" / "voicemail_drop".
Unknown types should be skipped, not rejected.
message.speaker: "user" (caller / callee) | "agent".
message.rating: "positive" | "negative" | null — per-turn
thumb feedback from in-dashboard reviewers.
message.toolCalls[]: present when the agent invoked one or more tools
during that turn. Empty array [] otherwise. Each entry is the recorded
ToolCallRecord:
message.retrievedChunks[]: present when the agent retrieved
knowledge-base context for that turn. Each entry: { kbId, kbName, documentId, documentName, contentPreview, score }.
Field availability across voice events
The canonical envelope (id, agentId, agentName, channel,
direction, contact, call) is present on every voice event with
the same shape. The matrix below covers only the per-event extras.
| Field path | started | ended | analyzed | failed |
|---|---|---|---|---|
| Canonical envelope (identity, contact, call) | ✅ | ✅ | ✅ | ✅ |
startedAt | ✅ | ✅ | ✅ | ✅ |
status, endReason | — | ✅ | ✅ | ✅ |
endedAt, durationSeconds, messageCount | — | ✅ | ✅ | ✅ |
messages[] | — | ✅ | ✅ | ✅ (often []) |
recording.url (signed) | — | null | ✅ | null |
recording.providerUrl | — | populated when available | populated when available | usually null |
recording.sizeBytes | — | null | ✅ | null |
recording.offsetMs | — | ✅ | ✅ | null |
usage.* | — | ✅ | ✅ | ✅ (may be 0 / null) |
cost.* | — | "0" (interim) | ✅ (final) | "0" (interim) |
analysis | — | null | ✅ (null in degraded states) | null |
error | — | — | — | ✅ |
createdAt, updatedAt | — | ✅ | ✅ | ✅ |
Convention: within a block (contact, call, recording, usage,
cost), every field exists with a union type (Field | null). You can
pick one event and parse defensively without property-existence checks.
Fields explicitly removed from voice payloads
The following fields existed in the previous (legacy) voice payload and are dropped in this shape. They were internal bookkeeping that leaked implementation details:
agentNumber, snapshotSttProvider, snapshotSttModel,
snapshotTtsProvider, snapshotTtsModel, snapshotTtsVoiceId,
latencyMetrics, piiRedacted, postCallStartedAt,
postCallProcessedAt, postCallStatus,
flat costStt / costLlm / costTts / costTelephony / costTotal
(replaced by nested cost{}), flat llmProvider / llmModel /
promptTokens / completionTokens / ttsCharCount / sttDurationMs
(replaced by nested usage{}), flat contactPhone / contactEmail /
contactName / contactId / contactCustomAttributes (replaced by
nested contact{}), flat externalCallId / telephonyProvider
(moved into call{}), flat recordingUrl / recordingSize /
providerRecordingUrl / recordingOffsetMs (replaced by nested
recording{}), flat csatScore / csatComment (CSAT is not yet
collected for voice — the field will be re-introduced as csat: { score, comment } matching chat when the collection mechanism ships),
sentiment / sentimentScore at the top level (kept inside
analysis.sentimentScore), metadata.
Per-disposition events: call.answered / call.no_answer / call.voicemail
Fired once per campaign dial alongside call.analyzed, after the
campaign updater classifies the dial outcome. Subscribe to these when
you want a single granular event per campaign contact instead of
parsing call.analyzed for status/AMD signals.
Mapping (from CampaignContact.status):
| Disposition | Event |
|---|---|
reached | call.answered |
no-answer | call.no_answer |
voicemail | call.voicemail |
failed | call.failed |
| When | After call.analyzed, for campaign-originated calls only |
|---|---|
| Default | Off |
call.no_answer and call.voicemail share the identical shape; only the
event value and disposition string change ("no-answer" /
"voicemail").
Non-campaign calls never fire these events — subscribe to call.ended /
call.analyzed for inbound and manual outbound calls.
Chat (chat.*)
Fired for chat-agent sessions on the embedded chatbot widget.
Payload shape (v2) The chat payloads documented below are the finalized shape, rolling out to organizations on a staggered schedule. They drop voice-only fields (telephony, recording, STT/TTS cost, audio snapshots, latency metrics) and ship the full
messages[]transcript inline. New organizations get v2 by default; existing organizations are migrated on a per-org flag.Forward-compat: every entry in
messages[]carries atypefield. Today it is always"text". Future kinds (form_submit,attachment,option_select,postback,rich) will reuse the same field — design your handler to switch onmessage.typeand ignore unknown values.
Standard envelope
All chat webhook deliveries share this outer shape:
Canonical data shape
Predictability is the contract. Every chat event ships these blocks
under data with the same field names in the same place. Events add their
own extras after these blocks but never reshape them — write one parser for
contact / visitor / context and reuse it across all five events.
Field meanings:
id— chat record id (cuid). The same value acrosschat.started→chat.lead.captured→chat.ended→chat.analyzedfor one session.contact— the person record.contact.idis your CRM-style identifier;contact.email/name/phoneare populated only after achat.lead.captured(or via existing-contact lookup). All fields arenull(not omitted) until known.visitor— the browser/device + conversation session.visitor.ididentifies the browser instance (was previously calledcookieId).visitor.sessionIdis the per-conversation WebSocket session UUID. Same person on phone and laptop = twovisitor.ids, onecontact.id.context— the page the chat is happening on.
Unknown fields within a block are null, never omitted. You can rely on
the block existing on every event.
chat.started
Fires when a visitor opens the widget and a new session begins. Off by
default. Adds startedAt to the canonical envelope; no conversation
content yet. The contact block is typically all-null unless the
visitor is a returning lead.
| When | On WebSocket session open |
|---|---|
| Default | Off |
chat.lead.captured
Fires the moment a visitor submits the chat lead-capture form. This is
time-sensitive — CRMs typically want it at submission, not at session-end.
The latest captured lead is also bundled into chat.ended and
chat.analyzed under data.contact, so subscribers on those events still
see the values without subscribing here.
| When | Immediate, on form submission |
|---|---|
| Default | On |
Dedup behavior: every submission that changes the captured contact
state fires. Byte-identical retries (double-clicks, network replays)
collapse to a single delivery. If the visitor edits their details later
in the same session — for example, adds a phone number to a prior
name+email capture — the event fires again with isUpdate: true.
Receivers should dedupe by data.contact.id if they want stricter
"one row per contact" semantics; the sender deliberately doesn't make
that decision for you.
chat.ended
Fires after 30 minutes of inactivity since the last visitor message. Carries the full conversation but not the analysis (which runs after).
| When | ~30 min after last message |
|---|---|
| Default | On |
chat.analyzed
Fires after the chat post-processing pipeline finishes. Identical shape to
chat.ended plus analysis populated, final cost figures, and csat if
the visitor responded to the CSAT prompt. Subscribe to this event if you want
everything in one POST.
| When | After post-processing completes (~5–60 s after chat.ended) |
|---|---|
| Default | On |
Showing only the deltas from chat.ended:
chat.failed
Fires on terminal chat session error (pipeline crash, KB unavailable,
upstream LLM outage). Same shape as chat.ended plus an error block.
No chat.analyzed follows.
| When | On terminal failure |
|---|---|
| Default | On |
Field availability across chat events
The canonical envelope (id, agentId, agentName, channel, direction,
contact, visitor, context) is present on every chat event with the
same shape. The matrix below covers only the per-event extras.
| Field path | started | ended | analyzed | failed | lead.captured |
|---|---|---|---|---|---|
| Canonical envelope | ✅ | ✅ | ✅ | ✅ | ✅ |
startedAt | ✅ | ✅ | ✅ | ✅ | — |
status, endReason | — | ✅ | ✅ | ✅ | — |
endedAt, durationSeconds, messageCount | — | ✅ | ✅ | ✅ | — |
messages[] | — | ✅ | ✅ | ✅ (partial) | — |
usage.* | — | ✅ | ✅ | ✅ | — |
cost.* | — | ✅ (interim) | ✅ (final) | ✅ (interim) | — |
csat | — | null | populated if responded | null | — |
analysis | — | null | ✅ | null | — |
error | — | — | — | ✅ | — |
consent, capturedAt, isUpdate, formId | — | — | — | — | ✅ |
SMS (sms.*)
Fired for SMS-agent conversations.
sms.started
Fires on the first inbound SMS from a contact (when a new session record is created). Off by default.
| When | On first inbound message of a new session |
|---|---|
| Default | Off |
| Payload | { callId, agentId, direction: "inbound", contactPhone } |
sms.ended
Fires after 24 hours of inactivity since the last inbound message. SMS is asynchronous — recipients often reply hours later, so the timeout is long.
| When | ~24 h after last inbound message |
|---|---|
| Default | On |
| Payload | Full Call record. Costs and analysis may be null/0. |
sms.analyzed
Fires after SMS post-processing completes. Full payload.
| When | After post-processing completes (seconds after sms.ended) |
|---|---|
| Default | On |
| Payload | Same as sms.ended plus cost.* populated. |
sms.failed
Fires on send/receive errors.
| When | On terminal failure |
|---|---|
| Default | On |
WhatsApp (whatsapp.*)
Fired for WhatsApp-agent conversations. Mirrors the SMS shape.
whatsapp.started
Off by default. Fires on the first inbound message of a new session.
whatsapp.ended
Fires after ~23.5 hours of inactivity. The 30-min buffer before Meta's 24-hour customer-service-window expires ensures the webhook lands while free-form replies are still allowed.
whatsapp.analyzed
Fires after WhatsApp post-processing completes. Full payload.
whatsapp.failed
Fires on send/receive errors.
Organization-scoped events
These events have no specific firing agent — they fan out to every subscribed webhook in the organization regardless of scope.
campaign.completed
Fires when an outbound campaign finishes (all contacts processed).
| When | On campaign completion |
|---|---|
| Default | On |
| Payload | { campaignId, agentId, status, totals: { contacts, called, reached, missed }, startedAt, endedAt } |
contact.reached
Fires when a campaign successfully reaches a contact (call answered).
| When | On call connect during a campaign |
|---|---|
| Default | On |
| Payload | { campaignId, contactId, callId, agentId, contactPhone } |
Notes on payload stability
- All keys are camelCase and stable. We may add fields in future versions — design your handler to ignore unknown keys gracefully.
- We will not silently rename or remove fields without a migration window.
- Decimals (e.g.,
cost.*) are sent as JSON numbers. Use precision-safe parsing if you need exact values (e.g.,Decimal.js).
See also
- Webhooks overview — setup, scope, authentication, retries
- Verifying webhook signatures — HMAC verification examples