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",
"from_number": "+14155550199",
"product": "spark",
"recording_url": "https://storage.example.com/…",
"start_time": "2026-04-20T18:24:10.113Z",
"status": "completed",
"to_number": "+15551234567",
"transcripts": [ /* see Transcript format */ ],
"transfer_number": null,
"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 |
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 |
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 |
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 |
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 |
superseded | The session was replaced by a newer one |
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 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.