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",
    "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"
}
FieldTypeDescription
call_idintegerStable across every event for this call
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
transfer_numberstring | nullSet when the call was transferred
recording_urlstring | nullExpiring signed URL; download promptly. null when no recording artifact is available
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

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
supersededThe session was replaced by a newer one
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 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.