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:

EventWhenPayload
*.startedSession opensIdentifier-only ping. Off by default.
*.endedSession closesImmediate. Whatever data we have at hangup/timeout — analysis, cost, and recordingUrl may be null.
*.analyzedPost-processing completeFull payload with analysis, cost, recording URL.
*.failedTerminal 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 contact block, same usage / cost / analysis blocks. Channel-specific fields live in a dedicated call block.

Forward-compat: every entry in messages[] carries a type field. Today it is always "text". Future kinds (tool_call, tool_result, transfer, voicemail_drop) will reuse the same field — design your handler to switch on message.type and ignore unknown values.

Standard envelope

All voice webhook deliveries share this outer shape (identical to chat):

{
  "event": "call.<lifecycle>",
  "timestamp": "2026-05-20T13:17:02.921Z",
  "organizationId": "org_xxx",
  "data": { /* see canonical voice data shape below */ }
}

Headers (unchanged from the Webhooks overview):

  • Content-Type: application/json
  • X-Nerva-Signature: <hex sha256 hmac> when webhookSecret is set
  • Authorization: … per the integration's auth config

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.

{
  "id":         "call_cmp5iiqbs000v7ag29e0cp7g2",
  "agentId":    "cmogwz9xr000204l4v4h0ido1",
  "agentName":  "Sales Outbound",
  "channel":    "voice",
  "direction":  "inbound",
 
  "contact": {
    "id":               "cmoic7rmh0001ekyksltq5yah",
    "phone":            "+15551234567",
    "email":            null,
    "name":             "Jane Caller",
    "phoneVerified":    false,
    "emailVerified":    false,
    "customAttributes": { "vipTier": "gold" }
  },
 
  "call": {
    "fromNumber":        "+15551234567",
    "toNumber":          "+18005550100",
    "telephonyProvider": "twilio",
    "externalCallId":    "CA1234abcd...",
    "campaignId":        null
  },
 
  "metadata": { "crmContactId": "abc-4421", "source": "hubspot-workflow" }
}

Field meanings:

  • id — voice call record id (cuid). The same value across call.startedcall.endedcall.analyzed for one call. Use it as your idempotency key together with event.
  • agentId / agentName — the voice agent that handled the call; agentName is 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.id is your CRM-style identifier; the rest is populated as we learn it. All fields are null (not omitted) until known. Identical shape to the chat contact block.
  • call — the telephony leg identifiers and routing. Equivalent of chat's visitor + context blocks, but for phone calls. call.campaignId is non-null only for calls dialed by a campaign.
  • metadata — customer-supplied passthrough key/value pairs from the metadata field on POST /v1/calls. Echoed verbatim on every call.* event for the call, so you can correlate with your own records (CRM IDs, source tags, internal call IDs) without an extra lookup. null when no metadata was supplied. Inbound calls always have metadata: null for 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.

WhenImmediate, on call create
DefaultOff
{
  "event": "call.started",
  "timestamp": "2026-05-20T13:16:46.500Z",
  "organizationId": "org_xxx",
  "data": {
    "id":         "call_cmp5iiqbs000v7ag29e0cp7g2",
    "agentId":    "cmogwz9xr000204l4v4h0ido1",
    "agentName":  "Sales Outbound",
    "channel":    "voice",
    "direction":  "inbound",
    "contact": {
      "id":               null,
      "phone":            "+15551234567",
      "email":            null,
      "name":             null,
      "phoneVerified":    false,
      "emailVerified":    false,
      "customAttributes": null
    },
    "call": {
      "fromNumber":        "+15551234567",
      "toNumber":          "+18005550100",
      "telephonyProvider": "twilio",
      "externalCallId":    "CA1234abcd...",
      "campaignId":        null
    },
    "startedAt": "2026-05-20T13:16:46.406Z",
    "metadata": null
  }
}

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.

WhenImmediate, on hangup
DefaultOn
{
  "event": "call.ended",
  "timestamp": "2026-05-20T13:18:35.011Z",
  "organizationId": "org_xxx",
  "data": {
    "id":         "call_cmp5iiqbs000v7ag29e0cp7g2",
    "agentId":    "cmogwz9xr000204l4v4h0ido1",
    "agentName":  "Sales Outbound",
    "channel":    "voice",
    "direction":  "inbound",
 
    "contact": {
      "id":               "cmoic7rmh0001ekyksltq5yah",
      "phone":            "+15551234567",
      "email":            null,
      "name":             "Jane Caller",
      "phoneVerified":    false,
      "emailVerified":    false,
      "customAttributes": { "vipTier": "gold" }
    },
 
    "call": {
      "fromNumber":        "+15551234567",
      "toNumber":          "+18005550100",
      "telephonyProvider": "twilio",
      "externalCallId":    "CA1234abcd...",
      "campaignId":        null
    },
 
    "status":          "completed",
    "endReason":       "disconnect",
 
    "startedAt":       "2026-05-20T13:16:46.406Z",
    "endedAt":         "2026-05-20T13:18:34.811Z",
    "durationSeconds": 108,
    "messageCount":    6,
 
    "messages": [
      {
        "id":              "msg_01",
        "speaker":         "agent",
        "type":            "text",
        "content":         "Thanks for calling Acme — how can I help?",
        "startMs":         640,
        "endMs":           3120,
        "rating":          null,
        "toolCalls":       [],
        "retrievedChunks": []
      },
      {
        "id":              "msg_02",
        "speaker":         "user",
        "type":            "text",
        "content":         "Hi, I'd like to upgrade my plan.",
        "startMs":         4100,
        "endMs":           6200,
        "rating":          null,
        "toolCalls":       [],
        "retrievedChunks": []
      },
      {
        "id":              "msg_03",
        "speaker":         "agent",
        "type":            "text",
        "content":         "Great. Let me pull up your account...",
        "startMs":         6900,
        "endMs":           9800,
        "rating":          "positive",
        "toolCalls": [
          {
            "name":    "lookup_contact",
            "args":    { "phone": "+15551234567" },
            "result":  { "contactId": "cmoic7rmh0001ekyksltq5yah" },
            "startMs": 7200,
            "endMs":   7950
          }
        ],
        "retrievedChunks": []
      }
    ],
 
    "recording": {
      "url":         null,
      "providerUrl": "https://api.twilio.com/.../Recordings/RE...",
      "sizeBytes":   null,
      "offsetMs":    240
    },
 
    "usage": {
      "llmProvider":    "openai",
      "llmModel":       "gpt-5.4-nano",
      "sttProvider":    "deepgram",
      "ttsProvider":    "cartesia",
      "promptTokens":   420,
      "completionTokens": 118,
      "sttDurationMs":  104320,
      "ttsCharCount":   612
    },
 
    "cost": {
      "stt":       "0",
      "llm":       "0",
      "tts":       "0",
      "telephony": "0",
      "total":     "0"
    },
 
    "analysis": null,
 
    "createdAt": "2026-05-20T13:16:46.408Z",
    "updatedAt": "2026-05-20T13:18:35.000Z"
  }
}

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.

WhenAfter post-call pipeline completes (~5–60 s after call.ended)
DefaultOn

Showing only the deltas from call.ended:

{
  "event": "call.analyzed",
  "timestamp": "2026-05-20T13:18:54.200Z",
  "organizationId": "org_xxx",
  "data": {
    // ... canonical envelope + all call.ended extras unchanged ...
 
    "recording": {
      "url":         "https://app.nerva.ctplai.org/api/recordings/call_cmp5iiqbs000v7ag29e0cp7g2.wav?token=eyJhbGciOi...",
      "providerUrl": "https://api.twilio.com/.../Recordings/RE...",
      "sizeBytes":   1843200,
      "offsetMs":    240
    },
 
    "cost": {
      "stt":       "0.012480",
      "llm":       "0.041800",
      "tts":       "0.018367",
      "telephony": "0.014400",
      "total":     "0.087047"
    },
 
    "analysis": {
      "summary": "Caller requested an upgrade to the Pro tier. Agent confirmed account, processed the upgrade, and quoted the new monthly rate.",
      "outcome": "successful",
      "topics": ["plan upgrade", "billing"],
      "actionItems": ["send upgrade confirmation email"],
      "languages": ["English"],
      "sentimentScore":  82,
      "resolutionScore": 90,
      "qualityScore":    88,
      "customData": {
        "intent":        "upgrade",
        "leadQualified": true,
        "tier":          "pro"
      }
    }
  }
}

Notes on the analysis block:

  • Identical schema to chat.analyzed.analysis — one parser handles both channels.
  • Scores (sentimentScore, resolutionScore, qualityScore) are 0–100 integers. null when the analysis pipeline couldn't score (e.g. zero-duration calls).
  • outcome enum: "successful" | "unsuccessful" | "escalated" | "missed".
  • customData is the agent's analysisSchema output — shape is per-agent and customer-controlled.
  • The block is null on call.ended and call.failed, populated on call.analyzed. In degraded states — missing transcript, analyzer LLM outage — analysis may also be null on call.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.providerUrl is the raw Twilio/Plivo URL, included for customers who want to fetch directly from the carrier. Short-lived; treat as opaque.
  • recording.offsetMs is the millisecond gap between startedAt and the moment recording actually began (carrier setup delay). Subtract from per-message startMs if 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.

WhenOn terminal failure
DefaultOn
{
  "event": "call.failed",
  "timestamp": "2026-05-20T13:17:08.011Z",
  "organizationId": "org_xxx",
  "data": {
    "id":         "call_cmp5iiqbs000v7ag29e0cp7g2",
    "agentId":    "cmogwz9xr000204l4v4h0ido1",
    "agentName":  "Sales Outbound",
    "channel":    "voice",
    "direction":  "outbound",
 
    "contact": {
      "id":               null,
      "phone":            "+15551234567",
      "email":            null,
      "name":             null,
      "phoneVerified":    false,
      "emailVerified":    false,
      "customAttributes": null
    },
 
    "call": {
      "fromNumber":        "+18005550100",
      "toNumber":          "+15551234567",
      "telephonyProvider": "twilio",
      "externalCallId":    null,
      "campaignId":        "cmc1aaaa0001bb22c333d444"
    },
 
    "status":          "failed",
    "endReason":       "pipeline_error",
 
    "startedAt":       "2026-05-20T13:17:02.406Z",
    "endedAt":         "2026-05-20T13:17:07.901Z",
    "durationSeconds": 5,
    "messageCount":    0,
 
    "messages":  [],
 
    "recording": {
      "url":         null,
      "providerUrl": null,
      "sizeBytes":   null,
      "offsetMs":    null
    },
 
    "usage": {
      "llmProvider":      null,
      "llmModel":         null,
      "sttProvider":      null,
      "ttsProvider":      null,
      "promptTokens":     0,
      "completionTokens": 0,
      "sttDurationMs":    0,
      "ttsCharCount":     0
    },
 
    "cost": {
      "stt":       "0",
      "llm":       "0",
      "tts":       "0",
      "telephony": "0",
      "total":     "0"
    },
 
    "analysis": null,
 
    "error": {
      "code":    "pipeline_error",
      "message": "Pipeline crashed during STT initialization"
    },
 
    "createdAt": "2026-05-20T13:17:02.408Z",
    "updatedAt": "2026-05-20T13:17:08.000Z"
  }
}

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

ValueMeaning
hangupEither side hung up normally (set by the telephony webhook handler)
disconnectPipeline-side end with no more specific reason — the common case for normally-ended calls
pipeline_errorInternal pipeline crash before completion
watchdog_timeoutStuck 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:

{
  "toolName": "transfer_call",   // tool identifier
  "toolType": "builtin",         // "builtin" | "webhook" | "mcp"
  "status":   "success",         // "success" | "error"
  "input":    { /* args passed to the tool */ },
  "output":   { /* result returned by the tool */ },
  "latencyMs": 1,                // tool execution latency in ms
  "error":    null               // error message string when status === "error"
}

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 pathstartedendedanalyzedfailed
Canonical envelope (identity, contact, call)
startedAt
status, endReason
endedAt, durationSeconds, messageCount
messages[]✅ (often [])
recording.url (signed)nullnull
recording.providerUrlpopulated when availablepopulated when availableusually null
recording.sizeBytesnullnull
recording.offsetMsnull
usage.*✅ (may be 0 / null)
cost.*"0" (interim)✅ (final)"0" (interim)
analysisnull✅ (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):

DispositionEvent
reachedcall.answered
no-answercall.no_answer
voicemailcall.voicemail
failedcall.failed
WhenAfter call.analyzed, for campaign-originated calls only
DefaultOff
{
  "event": "call.answered",
  "timestamp": "2026-05-20T13:19:01.000Z",
  "organizationId": "org_xxx",
  "data": {
    "callId":      "call_cmp5iiqbs000v7ag29e0cp7g2",
    "agentId":     "cmogwz9xr000204l4v4h0ido1",
    "campaignId":  "cmc1aaaa0001bb22c333d444",
    "contactId":   "cmoic7rmh0001ekyksltq5yah",
    "disposition": "reached",
    "durationSec": 108
  }
}

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 a type field. Today it is always "text". Future kinds (form_submit, attachment, option_select, postback, rich) will reuse the same field — design your handler to switch on message.type and ignore unknown values.

Standard envelope

All chat webhook deliveries share this outer shape:

{
  "event": "chat.<lifecycle>",
  "timestamp": "2026-05-14T13:17:02.921Z",
  "organizationId": "org_xxx",
  "data": { /* see canonical chat data shape below */ }
}

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.

{
  "id":         "cmp5iiqbs000v7ag29e0cp7g2",
  "agentId":    "cmogwz9xr000204l4v4h0ido1",
  "agentName":  "Customer Support",
  "channel":    "chat",
  "direction":  "inbound",
 
  "contact": {
    "id":               "cmoic7rmh0001ekyksltq5yah",
    "email":            "barber@hailey.com",
    "name":             "hailey barber",
    "phone":            null,
    "emailVerified":    false,
    "phoneVerified":    false,
    "customAttributes": { "courses": "Option 1" }
  },
 
  "visitor": {
    "id":              "vis_019e26c8-02ca-79ca-...",
    "sessionId":       "cd390ef7-a499-4118-99fe-e542722db451",
    "fingerprintHash": "f146198abefa690800629af730fe7c37",
    "locale":          "en-US"
  },
 
  "context": {
    "pageUrl":      "https://customer.com/pricing",
    "pageReferrer": "https://google.com",
    "origin":       "https://customer.com"
  }
}

Field meanings:

  • id — chat record id (cuid). The same value across chat.startedchat.lead.capturedchat.endedchat.analyzed for one session.
  • contact — the person record. contact.id is your CRM-style identifier; contact.email/name/phone are populated only after a chat.lead.captured (or via existing-contact lookup). All fields are null (not omitted) until known.
  • visitor — the browser/device + conversation session. visitor.id identifies the browser instance (was previously called cookieId). visitor.sessionId is the per-conversation WebSocket session UUID. Same person on phone and laptop = two visitor.ids, one contact.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.

WhenOn WebSocket session open
DefaultOff
{
  "event": "chat.started",
  "timestamp": "2026-05-14T13:16:46.408Z",
  "organizationId": "org_xxx",
  "data": {
    "id": "cmp5iiqbs000v7ag29e0cp7g2",
    "agentId": "cmogwz9xr000204l4v4h0ido1",
    "agentName": "Customer Support",
    "channel": "chat",
    "direction": "inbound",
    "contact": {
      "id": null, "email": null, "name": null, "phone": null,
      "emailVerified": false, "phoneVerified": false, "customAttributes": null
    },
    "visitor": {
      "id": "vis_019e26c8-02ca-79ca-...",
      "sessionId": "cd390ef7-a499-4118-99fe-e542722db451",
      "fingerprintHash": "f146198abefa690800629af730fe7c37",
      "locale": "en-US"
    },
    "context": {
      "pageUrl": "https://customer.com/pricing",
      "pageReferrer": "https://google.com",
      "origin": "https://customer.com"
    },
    "startedAt": "2026-05-14T13:16:46.406Z"
  }
}

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.

WhenImmediate, on form submission
DefaultOn

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.

{
  "event": "chat.lead.captured",
  "timestamp": "2026-05-14T13:00:28.111Z",
  "organizationId": "org_xxx",
  "data": {
    "id": "cmp5iiqbs000v7ag29e0cp7g2",
    "agentId": "cmogwz9xr000204l4v4h0ido1",
    "agentName": "Customer Support",
    "channel": "chat",
    "direction": "inbound",
    "contact": {
      "id": "cmp2gp0fo00074ssani36k18b",
      "email": "barber@hailey.com",
      "name": "hailey barber",
      "phone": null,
      "emailVerified": false,
      "phoneVerified": false,
      "customAttributes": { "courses": "Option 1" }
    },
    "visitor": {
      "id": "vis_019e26c8-02ca-79ca-...",
      "sessionId": "cd390ef7-a499-4118-99fe-e542722db451",
      "fingerprintHash": "f146198abefa690800629af730fe7c37",
      "locale": "en-US"
    },
    "context": {
      "pageUrl": "https://customer.com/pricing",
      "pageReferrer": null,
      "origin": "https://customer.com"
    },
    "consent": null,
    "capturedAt": "2026-05-14T13:00:26.794Z",
    "isUpdate": true,
    "formId": "leadForm"
  }
}

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
DefaultOn
{
  "event": "chat.ended",
  "timestamp": "2026-05-14T13:46:48.000Z",
  "organizationId": "org_xxx",
  "data": {
    "id": "cmp5iiqbs000v7ag29e0cp7g2",
    "agentId": "cmogwz9xr000204l4v4h0ido1",
    "agentName": "Customer Support",
    "channel": "chat",
    "direction": "inbound",
    "contact": {
      "id": "cmoic7rmh0001ekyksltq5yah",
      "email": "barber@hailey.com",
      "name": "hailey barber",
      "phone": null,
      "emailVerified": false,
      "phoneVerified": false,
      "customAttributes": { "courses": "Option 1" }
    },
    "visitor": {
      "id": "vis_019e26c8-02ca-79ca-...",
      "sessionId": "cd390ef7-a499-4118-99fe-e542722db451",
      "fingerprintHash": "f146198abefa690800629af730fe7c37",
      "locale": "en-US"
    },
    "context": {
      "pageUrl": "https://customer.com/pricing",
      "pageReferrer": "https://google.com",
      "origin": "https://customer.com"
    },
 
    "status": "completed",
    "endReason": "inactivity_timeout",
 
    "startedAt": "2026-05-14T13:16:46.406Z",
    "endedAt": "2026-05-14T13:46:46.406Z",
    "durationSeconds": 1800,
    "messageCount": 4,
 
    "messages": [
      {
        "id": "msg_01",
        "speaker": "user",
        "type": "text",
        "content": "hi",
        "startMs": 0,
        "endMs": 0,
        "rating": null,
        "toolCalls": [],
        "retrievedChunks": []
      },
      {
        "id": "msg_02",
        "speaker": "agent",
        "type": "text",
        "content": "Hello! How can I assist you today?",
        "startMs": 6692,
        "endMs": 6692,
        "rating": null,
        "toolCalls": [],
        "retrievedChunks": []
      },
      {
        "id": "msg_03",
        "speaker": "user",
        "type": "text",
        "content": "what's your pricing for the pro tier?",
        "startMs": 12400,
        "endMs": 12400,
        "rating": null,
        "toolCalls": [],
        "retrievedChunks": []
      },
      {
        "id": "msg_04",
        "speaker": "agent",
        "type": "text",
        "content": "The Pro tier is $49/month. Want me to walk you through the included features?",
        "startMs": 18012,
        "endMs": 18012,
        "rating": "positive",
        "toolCalls": [],
        "retrievedChunks": [
          {
            "kbId": "kb_pricing",
            "kbName": "Pricing FAQ",
            "documentId": "doc_pro_tier",
            "documentName": "Pro tier overview",
            "contentPreview": "The Pro tier includes...",
            "score": 0.92
          }
        ]
      }
    ],
 
    "usage": {
      "llmProvider": "azure",
      "llmModel": "gpt-5.4-nano",
      "promptTokens": 1240,
      "completionTokens": 380
    },
    "cost": {
      "llm": "0.012400",
      "total": "0.012400"
    },
 
    "csat": null,
    "analysis": null,
 
    "createdAt": "2026-05-14T13:16:46.408Z",
    "updatedAt": "2026-05-14T13:46:48.000Z"
  }
}

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.

WhenAfter post-processing completes (~5–60 s after chat.ended)
DefaultOn

Showing only the deltas from chat.ended:

{
  "event": "chat.analyzed",
  "timestamp": "2026-05-14T13:47:02.921Z",
  "organizationId": "org_xxx",
  "data": {
    // ... canonical envelope + all chat.ended extras unchanged ...
 
    "csat": {
      "score": 4,
      "comment": "Quick and helpful"
    },
 
    "analysis": {
      "summary": "Visitor asked about Pro tier pricing, was quoted $49/month, and asked about included features.",
      "outcome": "successful",
      "topics": ["pricing", "pro tier"],
      "actionItems": ["follow up with feature walkthrough if visitor returns"],
      "languages": ["English"],
      "sentimentScore": 78,
      "resolutionScore": 65,
      "qualityScore": 82,
      "customData": {
        "leadQualified": true,
        "tier": "pro"
      }
    }
  }
}

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.

WhenOn terminal failure
DefaultOn
{
  "event": "chat.failed",
  "timestamp": "2026-05-14T13:18:02.000Z",
  "organizationId": "org_xxx",
  "data": {
    // ... canonical envelope + chat.ended extras ...
    "status": "failed",
    "endReason": "error",
    "error": {
      "code": "llm_provider_unavailable",
      "message": "Upstream LLM provider returned 503 after retries"
    }
    // analysis and cost may be partial or zero
  }
}

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 pathstartedendedanalyzedfailedlead.captured
Canonical envelope
startedAt
status, endReason
endedAt, durationSeconds, messageCount
messages[]✅ (partial)
usage.*
cost.*✅ (interim)✅ (final)✅ (interim)
csatnullpopulated if respondednull
analysisnullnull
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.

WhenOn first inbound message of a new session
DefaultOff
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
DefaultOn
PayloadFull Call record. Costs and analysis may be null/0.

sms.analyzed

Fires after SMS post-processing completes. Full payload.

WhenAfter post-processing completes (seconds after sms.ended)
DefaultOn
PayloadSame as sms.ended plus cost.* populated.

sms.failed

Fires on send/receive errors.

WhenOn terminal failure
DefaultOn

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

WhenOn campaign completion
DefaultOn
Payload{ campaignId, agentId, status, totals: { contacts, called, reached, missed }, startedAt, endedAt }

contact.reached

Fires when a campaign successfully reaches a contact (call answered).

WhenOn call connect during a campaign
DefaultOn
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