Skip to main content
All events share the envelope; data.object is always "call" and data.call is the call object below - the same shape as GET /v1/calls/{id}. Fields fill in as the call progresses: cost, custom_analysis and transfer are null until they apply. Current API version: 2026-08-01 The three terminal events fire for every call - including one that was transferred to a human (see Transfers).

The call object

Present as data.call on every event - the same object as GET /v1/calls/{id}, so you learn one shape across REST and webhooks. Fields are null until the relevant stage finishes.
string
The call id — the same id that POST /v1/calls returns.
string | null
The agent that ran the call.
string
Where the call got to. Mid-call: started, in_progress, transferring, transfer_answered. Finished: completed, voicemail, no_answer, busy, declined, failed, transfer_completed, transfer_failed. Open set — treat it as a string rather than switching exhaustively.
string
Which way the call went: inbound, outbound, or web for a call that happened in a browser rather than on a phone. A web call carries no numbers. For WHO started it, read origin.
string | null
Caller number.
string | null
Destination number.
integer | null
Connected duration.
string
When the call was created (ISO 8601, UTC).
string | null
When it connected.
string | null
When it ended.
object | null
Your passthrough JSON from POST /v1/calls.
object | null
The call’s dynamic/custom variables - from POST /v1/calls variables, or resolved for inbound (e.g. caller_no). Only your custom variables, not internal runtime state.
object | null
{ currency: "USD", total, rate_per_min } - null until billed.
object | null
Every value the call’s analysis groups extracted, keyed by group name then analysis name - including Vocily’s built-in Default Analysis group. null until analyzed.
object
The conversation.
object | null
Present only if a transfer happened: { state, target, reason }. state is transferring | answered on call.transfer.status, and completed | failed on call.ended. Persists onto later events.
Recordings on webhooks. The webhook payload does not include the stable recording_url. Once a recording is ready, call.processing.completed carries a ready-to-use, expiring recording_presigned_url (plus recording_url_expires_at), minted fresh for each delivery. Do not cache or store the pre-signed URL - it expires. For durable access, call GET /v1/calls/{id}/recording (Vocily’s stable endpoint) with your API key.

Call started

call.started - the call was placed and is dialing/ringing. Identity, routing, and your metadata/variables are present; timing, cost, transcript, analysis, and recording are not yet available.

Call transfer status

call.transfer.status - a human-transfer state changed, in-call:
  • transferring - dialling the human.
  • answered - the human actually picked up (bridged).
  • failed - the human didn’t answer (busy / no-answer).
The successful-and-finished outcome, completed, is reported on call.ended, not here - this event only carries in-call progress.

Call ended

call.ended - the call reached a terminal status. The core outcome event, with transcript, timings, and cost.

Call analysis completed

call.analysis.completed - post-call analysis finished. The call’s custom_analysis field is now populated with every value its analysis groups extracted:
custom_analysis is keyed by group name, then analysis name - exactly the names you typed in the library, so renaming one changes the key later calls publish. See Custom Analysis.
There is no separate analysis object. Vocily’s built-in summary, sentiment, outcome and language are the Default Analysis group, attached to every new agent, and arrive inside custom_analysis like every other extracted value - so there is one place to read analysis from, not two. custom_analysis["Default Analysis"]["User Sentiment"] is positive, neutral, or negative; Outcome and Language are free text. A caller who detaches the group, or switches one of its values off, simply gets no key for it.

Call processing completed

call.processing.completed - the recommended default subscription. A fan-in event that fires once analysis and the recording are terminal - the complete record in a single payload. Beyond the call object it adds a dashboard deep link and explicit status fields so a null reads as “not produced” rather than “not ready”:
Subscribe to call.processing.completed for the full record in one event, or to call.ended if you need the outcome the instant the call hangs up (before analysis/recording are ready). Fetch the recording durably via GET /v1/calls/{id}/recording - don’t cache recording_presigned_url.

Transfers

A transfer runs as part of the same call. In-call progress arrives on call.transfer.status; the terminal outcome on call.ended:
  • Progress: call.transfer.status{transferring} (dialling) → call.transfer.status{answered} (the human actually picked up).
  • Failed (busy / no-answer): call.transfer.status{failed}, then the call ends → call.ended with transfer.state = "failed".
  • Answered, then the bridged call ends: call.ended fires when that bridged call actually ends, with transfer.state = "completed", followed by call.analysis.completed and call.processing.completed - so the terminal events fire for transferred calls too.
Note: transcript and the recording cover the call up to the handoff - the human↔caller conversation after the bridge is not captured. duration_seconds and cost cover the whole call, including the time after the handoff, because the phone network keeps billing the line for all of it.
On a transferred call, call.ended fires when the bridge ends, which is a few seconds before the carrier reports the call’s final duration. The cost in that payload is therefore provisional and usually lower than the final charge. Read the settled figure from GET /v1/calls/{id} or the billing ledger.