Open in
telephony.complete / web.complete
Non-blocking webhook delivered when a call ends, with transcript, recording URL, and metrics.
A completion event fires after every call ends — inbound telephony, outbound telephony, web call, or test call (builder mic session). It is non-blocking: respond with any 2xx.
The event is delivered on both paths:
- Webhook endpoints receive
telephony.complete(phone calls) orweb.complete(web calls and builder mic test calls) with the stable payload documented below, a per-deliveryevent_id, a 30 s timeout, and retries for up to 24 h. - The legacy single-URL webhook receives one synchronous attempt (10 s timeout, no retries) with a slightly different payload — see Legacy payload differences.
Request payload (endpoint deliveries)
{
"data": {
"billable_minutes": 1.25,
"billing_total_cents": 8,
"call_id": 987654321,
"direction": "inbound",
"duration_seconds": 54,
"end_reason": "user_hangup",
"end_time": "2026-04-20T18:25:04.822Z",
"extracted_data": {
"status": "completed",
"fields": {
"customer_name": "Alex Morgan",
"appointment_date": "2026-04-23"
},
"evidence": {
"customer_name": {
"quote": "My name is Alex Morgan",
"speaker_role": "caller",
"turn_index": 4
},
"appointment_date": {
"quote": "April 23 works for me",
"speaker_role": "caller",
"turn_index": 7
}
},
"verification": "verified",
"field_reasons": {},
"schema_version": "92850758e231a3c95a..."
},
"from_number": "+14155550199",
"product": "spark",
"recording_stopped_at": null,
"recording_stopped_reason": null,
"recording_url": "https://storage.example.com/…",
"start_time": "2026-04-20T18:24:10.113Z",
"status": "completed",
"summary": "Alex Morgan called to book a follow-up visit and confirmed Thursday, April 23. The agent read the appointment back and Alex asked for a reminder text the day before.",
"to_number": "+15551234567",
"transcripts": [ /* see Transcript format */ ],
"transfer_number": null,
"unresolved_variables": ["campaign_owner"],
"variables": {"campaign_name": "Spring renewals"},
"voice": "john"
},
"event_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
"type": "telephony.complete"
}| Field | Type | Description |
|---|---|---|
call_id | integer | Stable across every event for this call |
agent_id | integer | null | Agent that handled the call, when one was assigned |
agent_name | string | null | Agent that handled the call, when one was assigned |
direction | string | inbound, outbound, web, test. Historical payloads may contain legacy mic or widget values |
from_number, to_number | string | E.164. from_number is the literal "web" for web calls and test calls |
origin_domain | string | Web/test only — the page origin that hosted the widget (empty for mic sessions) |
start_time, end_time | timestamp | ISO 8601 UTC |
duration_seconds | integer | null | Derived from start/end |
status | string | completed or failed |
end_reason | string | See table below |
product, voice | string | Agent config in effect at call time |
variables | object | Input variables snapshotted when the call started |
unresolved_variables | array | Variable names referenced by the call config but not supplied at call start |
transfer_number | string | null | Set when the call was transferred |
recording_url | string | null | Expiring signed URL; download promptly. null when no recording artifact is available |
recording_stopped_at | timestamp | null | When the recording was stopped before the call ended; nothing after this moment is in the recording. null when the recording ran to the end of the call or the call was not recorded |
recording_stopped_reason | string | null | Why the recording stopped early. warm_transfer: it stops just before a warm transfer calls the destination, and stays off even if the transfer fails and the caller returns to the agent. Treat other values as informational; more may be added. null when recording_stopped_at is null |
billable_minutes | number | Minutes billed, rounded to the nearest quarter minute (15-second increments, minimum 0.25). Straight-to-voicemail calls still report their actual metered minutes here, but the charge is capped at one minute at the plan rate. |
billing_total_cents | integer | USD cents |
transcripts | array | Per-turn transcript entries; can be empty when a transcript is unavailable |
summary | string | null | Plain-language post-call summary (two to four sentences of prose, at most 2,000 characters, written in the call's language: the agent's primary language unless the call overrode it) when the agent has Generate a call summary turned on under Data to collect in the agent builder (call_summary_enabled on the Agent) and generation succeeded; null otherwise, including for calls too short to summarize, calls with no transcript, and agents with recording turned off. The summary is generated in the same post-call step as extraction, so the Wait for extracted data before sending the call-finished webhook setting (extraction_blocks_completion) decides whether this event waits for it: in a waiting mode the summary is ready before this event is sent (the summary itself gets at most 30 seconds inside the step's 75-second budget); when the event does not wait (never, or a transfer under except_transfers) this field is null and the summary is not re-sent in call.data_extracted, so read it later from summary on the Call object. Also available on the call page in the dashboard |
extracted_data | object | null | Structured extraction result with status, fields, evidence, verification, field_reasons, and schema_version. Each non-null field has the exact structurally checked quote (at most 1,000 characters), plus its speaker role and turn index; longer model-returned quotes are rejected instead of truncated. Evidence is null when the field is null. verified means every candidate received exactly one valid independent verdict. unavailable also covers malformed or partial verifier output; valid partial verdicts still apply, while candidates without one valid verdict are nulled. status is completed, failed, exhausted, skipped, or skipped_recording_disabled; null when the agent had no extraction fields |
steps_trace | array | Only on calls whose agent has steps or handoffs. The order the call moved through them: each entry has t_ms (milliseconds since the call started), agent (the agent's key, a plus its ID), step (the step ID, or null on a handoff to an agent without steps) and via (start, model, condition, on_enter or handoff). Holds IDs and times only, so it is kept even when recording is off |
collected_values | object | Only on calls whose agent has steps or handoffs. The values the agent collected in its steps, keyed by collect field name, plus <tool>_result for each step's on_enter_tool result. Omitted when the call's content is not retained (recording turned off) |
End reasons
| Value | Meaning |
|---|---|
user_hangup | Remote party hung up first |
ai_hangup | AI ended the call deliberately |
ai_transfer | AI transferred the call; transfer_number is set |
ai_warm_transfer | AI completed a warm (attended) transfer |
voicemail_hangup | Voicemail was detected and the call ended per your voicemail_action |
max_duration | Call hit the maximum duration limit: the agent's or the call's own limit, or the 6-hour platform limit. A call the AI or the caller ends after the time limit warning keeps ai_hangup or user_hangup |
superseded | The session was replaced by a newer one |
inbound_config_webhook_failed | Inbound call to a number in webhook mode: the configuration webhook did not return a usable configuration, so the call was ended before an agent answered. status is failed and the call is not billed |
unknown | End reason could not be determined |
Transcript format
Each entry in transcripts is one conversational turn. Roles are
user (caller speech), model (agent speech and tool calls),
tool (tool results), and system (call events such as language
switches).
[
{
"role": "user",
"content_type": "text/plain",
"content": "Hi, I'm calling about my appointment.",
"start_ms": 1200,
"end_ms": 4100,
"audio_url": "https://storage.example.com/…"
},
{
"role": "model",
"content_type": "text/plain",
"content": "Sure, what date works best?",
"start_ms": 4200,
"end_ms": 6100
},
{
"role": "model",
"content_type": "application/json",
"content": {
"tool_call": "search_appointments",
"arguments": { "date": "2026-04-21" }
}
},
{
"role": "tool",
"content_type": "application/json",
"content": {
"tool_name": "search_appointments",
"response": { "available_slots": ["9:00 AM", "2:00 PM"] }
}
}
]| Field | Type | Description |
|---|---|---|
role | string | user, model, tool, or system |
content_type | string | text/plain for speech; application/json for tool calls, tool results, and system events |
content | string | object | Speech text, or the structured object shown above. Tool calls: {"tool_call": name, "arguments": {…}}. Tool results: {"tool_name": name, "response": {…}} |
start_ms, end_ms | integer | Offsets from call start, ms. Present when audio timing is known |
ttfa_ms | integer | Time-to-first-audio for a model turn, when measured |
audio_url, audio_urls | string / array | Expiring signed URLs for the turn's audio, when recorded per-turn |
For the fully structured turn history (with interruption markers,
ack-prompts, and raw positions), use
GET /v1/calls/{call_id}/history.
Legacy payload differences
The legacy single-URL webhook envelope is
{"type": "telephony.complete" | "web.complete", "data": {…}} with
no event_id, and its data differs from the endpoint payload:
The legacy completion payload also carries agent_id and agent_name.
- The turn array is under
history, nottranscripts(same turn schema as above). - The field set is the raw end-of-call report and can include additional internal fields beyond the table above — treat unknown fields as informational.
- Web calls (
direction: "web") omitfrom_number/to_numberand addorigin_domain. - Builder mic test calls report as
telephony.completeon the legacy path (the endpoint system maps them toweb.complete). - Transfer coordination: when a call ends in a transfer, the
legacy webhook is called synchronously and may answer
{"transfer_ready": false}to signal the handoff target isn't ready. Any other response (or no legacy webhook) lets the transfer proceed. Endpoint deliveries are never consulted for this.
Example handler
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["THUNDERPHONE_WEBHOOK_SECRET"]
def verify(body: bytes, signature: str) -> bool:
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
@app.post("/thunderphone-webhook")
async def webhook(request: Request):
body = await request.body()
if not verify(body, request.headers.get("X-ThunderPhone-Signature", "")):
raise HTTPException(status_code=401)
event = json.loads(body)
if event["type"] in ("telephony.complete", "web.complete"):
data = event["data"]
# Endpoint deliveries use "transcripts"; the legacy webhook uses "history".
turns = data.get("transcripts") or data.get("history") or []
await persist_call_record(
call_id=data["call_id"],
turns=turns,
recording_url=data.get("recording_url"),
)
if data["end_reason"] in ("ai_transfer", "ai_warm_transfer"):
await notify_team(data.get("transfer_number"), data["call_id"])
return {"ok": True}import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.THUNDERPHONE_WEBHOOK_SECRET;
function verify(body, signature) {
const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");
return signature &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
app.post(
"/thunderphone-webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
if (!verify(req.body, req.header("X-ThunderPhone-Signature"))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (["telephony.complete", "web.complete"].includes(event.type)) {
const data = event.data;
// Endpoint deliveries use "transcripts"; the legacy webhook uses "history".
const turns = data.transcripts ?? data.history ?? [];
await persistCallRecord({ ...data, turns });
if (["ai_transfer", "ai_warm_transfer"].includes(data.end_reason)) {
await notifyTeam(data.transfer_number, data.call_id);
}
}
res.json({ ok: true });
},
);Common use cases
Persist each call's transcript + recording URL alongside your customer records.
Stream transcripts to a pipeline for topic modeling, CSAT signal extraction, or transfer-rate monitoring.
Open calls in a QA tool for human review, or run them through your own evaluation model.
Trigger a human teammate on transfer / failure.