ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Webhooks

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:

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"
}
FieldTypeDescription
call_idintegerStable across every event for this call
agent_idinteger | nullAgent that handled the call, when one was assigned
agent_namestring | nullAgent that handled the call, when one was assigned
directionstringinbound, outbound, web, test. Historical payloads may contain legacy mic or widget values
from_number, to_numberstringE.164. from_number is the literal "web" for web calls and test calls
origin_domainstringWeb/test only — the page origin that hosted the widget (empty for mic sessions)
start_time, end_timetimestampISO 8601 UTC
duration_secondsinteger | nullDerived from start/end
statusstringcompleted or failed
end_reasonstringSee table below
product, voicestringAgent config in effect at call time
variablesobjectInput variables snapshotted when the call started
unresolved_variablesarrayVariable names referenced by the call config but not supplied at call start
transfer_numberstring | nullSet when the call was transferred
recording_urlstring | nullExpiring signed URL; download promptly. null when no recording artifact is available
recording_stopped_attimestamp | nullWhen 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_reasonstring | nullWhy 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_minutesnumberMinutes 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_centsintegerUSD cents
transcriptsarrayPer-turn transcript entries; can be empty when a transcript is unavailable
summarystring | nullPlain-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_dataobject | nullStructured 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_tracearrayOnly 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_valuesobjectOnly 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

ValueMeaning
user_hangupRemote party hung up first
ai_hangupAI ended the call deliberately
ai_transferAI transferred the call; transfer_number is set
ai_warm_transferAI completed a warm (attended) transfer
voicemail_hangupVoicemail was detected and the call ended per your voicemail_action
max_durationCall 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
supersededThe session was replaced by a newer one
inbound_config_webhook_failedInbound 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
unknownEnd 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"] }
    }
  }
]
FieldTypeDescription
rolestringuser, model, tool, or system
content_typestringtext/plain for speech; application/json for tool calls, tool results, and system events
contentstring | objectSpeech text, or the structured object shown above. Tool calls: {"tool_call": name, "arguments": {…}}. Tool results: {"tool_name": name, "response": {…}}
start_ms, end_msintegerOffsets from call start, ms. Present when audio timing is known
ttfa_msintegerTime-to-first-audio for a model turn, when measured
audio_url, audio_urlsstring / arrayExpiring 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, not transcripts (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") omit from_number / to_number and add origin_domain.
  • Builder mic test calls report as telephony.complete on the legacy path (the endpoint system maps them to web.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

Python (FastAPI)
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}
Node.js (Express)
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

CRM integration

Persist each call's transcript + recording URL alongside your customer records.

Analytics

Stream transcripts to a pipeline for topic modeling, CSAT signal extraction, or transfer-rate monitoring.

Quality review

Open calls in a QA tool for human review, or run them through your own evaluation model.

Notifications

Trigger a human teammate on transfer / failure.