Skip to main content

Webhook Payloads

This page documents the structure of webhook payloads sent by Vocobase when events occur.

Headers

Every webhook request includes these headers:

Event types

Branch on the top-level event field. These are the values accepted in an endpoint’s events allowlist — see Scoping & Event Filtering. Six event types, and that is the complete list. GET /api/v2/config/webhooks returns the same set as data.available_events, so you can read it from the API rather than hard-coding it. webhook.test is fired only by the test endpoint and is not a valid allowlist value — it always reaches the endpoint being tested. Handle or ignore it explicitly.

How a failed session surfaces

A session that ends in a terminal failure — carrier no-answer, busy, rejected, or a bot that never started — is delivered as session.completed, not as a separate failure event. Every session-terminal path builds its payload through the same builder, which always emits event: "session.completed", including the explicit failure path. Branch on the payload, not on the event name: data.call.status is the reliable discriminator for telephony sessions. For WebRTC sessions there is no call object at all, so use duration_seconds and transcript.
session.failed was previously published in this catalog with no producer behind it. It has been removed, and it is no longer accepted in an endpoint’s events allowlist — sending it now returns 400 VALIDATION_ERROR. Existing allowlists that contained it were stripped of it automatically; an endpoint left with an empty allowlist receives every event type.
inbound.route (below) is a pre-answer routing callback with its own headers and a required response body. It is not part of this event stream and cannot be subscribed to.

Event: webhook.test

Sent by POST /api/v2/config/webhooks/{id}/test. Signed and retried exactly like a real event, so receiving it confirms your URL, TLS, and signature verification all work end to end.
agent_id is the endpoint’s scope: null for an account-wide endpoint, the agent’s id for an agent-scoped one.

Event: inbound.route

This event is sent when an account-configured pre-answer policy is available for a Vobiz or Plivo inbound call.

Payload structure

Headers

Inbound route webhooks for this feature use feature-specific headers:

Response contract

Accounts return JSON with an action value:
  • ANSWER_WITH_AI
  • SELECT_AGENT
  • TRANSFER
  • REJECT
When action is TRANSFER, to_number must be E.164. When action is SELECT_AGENT, agent_id must be set. Vocobase records each resolved route decision for your own observability. Query GET /api/v2/inbound-route-decisions to inspect action, source, webhook status, webhook latency, failure reason, transfer target, and created session or phone-call IDs.

Event: call.status.updated

Sent whenever Vocobase learns that a telephony call moved to a new meaningful call status. This event is for real-time orchestration: live dashboards, your own dialer queues, and concurrency release. call.status.updated is sent for phone calls only. Browser WebRTC sessions do not have a carrier call leg and therefore do not emit this event.

Payload structure

Field reference

Do not build a call record from call.status.updated alone. This event is enqueued from the carrier’s callback, which can arrive several seconds before we have written the call’s final row. On a terminal update that means hangup_cause and hangup_source are frequently still null, end_call_reason usually carries only what the carrier said (not what our bot saw), and there is no end timestamp on this event at all — occurred_at marks the moment of the state transition, not the end of the call.Use call.status.updated for real-time dialer and concurrency orchestration. Use session.completed for the final record: it is built after the call row is settled and carries call.started_at, call.answered_at, call.ended_at and the final hangup attribution. If you need the record before session.completed arrives, poll GET /calls/{call_id}, which returns started_at and ended_at under those same names. answered_at is currently carried only on session.completed.

Call status values

Vocobase normalizes provider-specific call states into one vocabulary:

Provider normalization

Accounts should branch on data.status, not on provider-specific values. Vocobase stores the raw provider value for debugging.

External queue orchestration

If you run your own dialer, use call.status.updated to manage slots:
Do not wait for session.completed to release a dialer slot. session.completed is the final rich result after transcript, recording, billing, and extraction processing.

Event: session.completed

Sent when a Vocobase session has reached final post-call processing. For telephony calls, this happens after the call leg ends and the bot/session pipeline has persisted transcript, recording metadata, billing, variables, and extraction results.

Payload structure

Field reference

Data object fields

Transcript entry format

Each entry in the transcript array has:
recording_url is a presigned URL valid for 7 days from the moment the webhook fires. Download and persist the recording on your side if you need long-term retention. If the URL expires before you can download it, call GET /calls/{call_id}/recording-url to mint a fresh one with another 7-day window.

End call reason

end_call_reason answers one question — why did this call end? — with one vocabulary that reads the same on every carrier, including MCube and Exotel, which report no carrier hangup cause at all. It is never null on session.completed. UNKNOWN and NOT_CONNECTED are values, not absence: they say what we honestly know, rather than implying a conversation that nothing witnessed.

How it relates to disposition

They answer different questions and are both correct at once.
  • disposition is the coarse outcome bucket that drives campaign retry logic.
  • end_call_reason is why the call ended.
So these pairs are normal, not contradictions: Use disposition for retry logic. Use end_call_reason for reporting and for explaining an outcome to a human.

Caveats worth reading once

  • On call.status.updated, end_call_reason is null while the call is live. A reason for ending does not exist until the call ends.
  • On the terminal call.status.updated, it usually reflects the carrier half only. The carrier’s completion callback reaches us before our bot’s own end-of-call report is persisted, so an answered call typically reads COMPLETED on that event and, say, CUSTOMER_HUNG_UP on session.completed. session.completed is the authoritative one.
  • TRANSFERRED means the transfer was initiated, not that the human picked up: a busy transfer destination still reads TRANSFERRED.
  • TRANSFERRED is not reported on VoiceLink or MCube. Those carriers bridge the transfer in-band, so we never see it happen; a transferred call on them reads CUSTOMER_HUNG_UP. This is a known gap.
  • GET /calls and GET /calls/{call_id} can disagree on a call whose session failed but whose carrier leg completed. The single-call route reports the session’s status (failed, and so typically SYSTEM_ERROR); the list reports the carrier’s (completed, and so whatever the bot reported, or UNKNOWN). This is a pre-existing difference in how the two routes report status, which end_call_reason now follows. GET /calls/{call_id} is the more complete of the two.
  • GET /calls/{call_id} can report a transient failed / SYSTEM_ERROR in the short window after the carrier has ended a call but before our bot’s end-of-call report has been persisted (typically seconds; longer if the report is being retried). Once the report lands, the same call reads the bot’s reason. If you poll immediately after call.status.updated, prefer to wait for session.completed instead.
  • hangup_cause and hangup_source remain on every payload this release and are deprecated; they are removed in the next release. end_call_reason replaces both.

Call holds

Vocobase notices when a call is put on hold and reports every hold in hold. English, Hindi and Hinglish are recognised, plus common Punjabi and Marathi hold messages.
null is not the same as “no hold”. null means hold detection did not run for this call (it was off for the agent, or the call predates the feature). {"detected": false, ...} means it ran and found no hold. Long holds end the call. If a single hold lasts more than a minute, the agent says a short goodbye and hangs up, so a call parked on hold does not keep running. The call then reports end_call_reason: "COMPLETED" with hold.ended_on_hold: true. For a person asking to wait, only their silence counts toward the minute, not the agent’s own speech. Short waits are not holds. A person asking to wait is reported only if they stay quiet for at least 15 seconds; “one second” followed by more talking is ordinary conversation. A question (“one minute, who is this?”) is never a hold, and talking about a hold (“my loan is on hold”) is not one either. Holds inform extraction. When a hold is found, your post-call extraction is told about it, so an outcome such as “customer asked us to wait” is not missed just because the call carried on afterwards.
A hold message played by the phone network appears in transcript as a user turn, because it is heard on the customer’s side of the line. Use hold rather than the transcript to tell the two apart.
Hold detection is switched on per agent. Contact support to turn it off for an agent.

Final call status on session.completed

call.status in session.completed reflects the final known carrier state. For real-time intermediate statuses, use call.status.updated.

Call disposition values

Transferred calls

When an agent transfers a live call to a human:
  1. The bot speaks the configured announcement (transfer_message).
  2. Vocobase transfers the caller to transfer_target.
  3. The session.completed webhook includes transfer metadata.
The session.completed webhook fires once the bot leg ends, not when the caller eventually hangs up with the human. This means:
  • duration_seconds reflects only the bot leg (caller-on-with-bot time).
  • recording_url, when present, is the bot-leg recording. The human side is not recorded.
  • call.transferred is true; call.transferred_at is the redirect moment; call.transfer_target is the destination E.164.
Use call.transferred as the cheap discriminator if your downstream automation needs to distinguish a transferred call from a normal hangup.

IVR Flows calls

Calls placed with POST /ivr-flows/{flowId}/calls (and test calls from the IVR editor) carry an extra ivr block on session.completed. Every other field is unchanged. The lead you pushed is echoed as variables. agent_id and agent_name identify an internal agent that backs the flow; key your integration on ivr.flow_id.
ivr.fetches[].error is one of timeout, connect_error, url_blocked, too_large, bad_json, render_error, http_<status> (your API answered with a non-2xx status), missing_<field> (a required field was not in the response) or type_<field> (a field could not be read as its type). The ivr block never carries Fetch URLs, headers, request bodies or the values your API returned. Pre-dial failures. When a pre-dial Fetch fails, the number is never dialed. The call ends the way any failed session does: call.status is failed, call.end_call_reason is NOT_CONNECTED, duration_seconds and credits_used are 0, and transcript is null. ivr.failure is prefetch_failed, ivr.path ends at the Fetch that failed, and ivr.fetches shows why.

Event: call.transferred

Fired the moment a live call is handed off to a human, from the transfer route on a successful transfer. The call is still in progress when this fires, so there is no duration, disposition, transcript, or recording yet — transferred_at and transfer_target are the payload. Emitted for phone calls only. Browser WebRTC sessions cannot be transferred.

Payload structure

Field reference

call.transferred does not replace session.completed. A transferred call still emits session.completed when the bot leg ends, with call.transferred: true. Use call.transferred when you need to react at hand-off time; use session.completed for the final record.

Events: whatsapp.message.*

Three events cover outbound WhatsApp messaging. They share one payload shape and differ only in the event value and the data.status that triggered them.

Payload structure

These payloads have no top-level timestamp. Every other event in this reference carries one; the WhatsApp events carry data.ts instead. If your handler reads event.timestamp unconditionally it will get undefined here. Fall back to data.ts, or to the X-Webhook-Timestamp header, which is sent on every delivery including these.

Field reference

data.status is uppercase here, unlike call.status.updated and session.completed, whose call statuses are lowercased. Compare case-insensitively if you normalize statuses across event types.
Scope resolution: the messaging log has no agent of its own, so the agent is resolved through the message’s session when there is one. A message with no session is agentless and therefore reaches account-wide endpoints only — an agent-scoped endpoint will never see it. See Scoping & Event Filtering.

Polling GET /calls/{id} vs webhook

Both the webhook and GET /api/v2/calls/{id} return the same data shape, so an account that can’t accept inbound webhooks (or wants to reconcile dropped deliveries) can poll the GET endpoint instead. A call can appear completed before all transcript, recording, and extraction fields are ready. The processing_complete boolean tells you when the response is final:
In practice the session.completed webhook will reach you before polling kicks in — polling is the fallback for accounts that can’t accept inbound webhooks, or for reconciliation after a delivery failure.
For sessions created before this field shipped, pipeline_completed_at will be null permanently. Treat null + status: completed on an older row as “final” — those rows pre-date the signal.

Post-call extraction

When the agent has a Custom Analysis config defined, the platform runs an LLM extraction over the transcript after every completed call and ships the result inline in the same session.completed webhook — no second webhook to handle. Configure extraction via the dashboard’s “Variables & Analytics” tab on the agent editor or via PUT /api/v2/agent/:id with extraction_config.

Extraction object structure

Extraction failures never block webhook delivery. The unified payload always ships with extraction.status indicating outcome — accounts that need extraction values can branch on status === 'success', accounts that just need transcript + recording can ignore the field.

Replay extraction against a past session

If you edit extraction_config and want to backfill an existing session, call POST /api/v2/calls/{call_id}/extract with { "dry_run": false }. Optional dry_run: true returns the result without persisting. The replay uses the agent’s current config (not the config at original-call time).

Pre-call variables

The variables field on every webhook echoes the per-call values that were substituted into the agent’s prompt and greeting. Configure variable names via the dashboard “Variables & Analytics” tab or PUT /api/v2/agent/:id with variables: ["callee_name", "mobile_number"]. Supply per-call values via POST /api/v2/calls/start body field variables: { callee_name: "Sajal" }. Missing values render as empty strings — the platform never errors on missing variables.

Next steps

Webhook Setup

Configure webhook endpoints and verify signatures.

Error Handling

Understand retry behavior and failure handling.