Skip to main content
The call.complete webhook fires after every call ends — inbound telephony, outbound telephony, widget, or mic session. It is non-blocking: respond with any 2xx status within 30 s. We retry on non-2xx with exponential backoff for up to 24 hours.
In the endpoint-based delivery system, the equivalent event types are telephony.complete (phone calls) and web.complete (widget / mic). The legacy single-URL webhook uses call.complete for both. See the events catalog.

Request payload

{
  "type": "call.complete",
  "data": {
    "call_id": 987654321,
    "direction": "inbound",
    "from_number": "+14155550199",
    "to_number": "+15551234567",
    "start_time": "2026-04-20T18:24:10.113Z",
    "end_time":   "2026-04-20T18:25:04.822Z",
    "duration_seconds": 54,
    "status": "completed",
    "end_reason": "caller_hangup",
    "product": "spark",
    "voice": "en-US-James1",
    "transfer_number": null,
    "recording_url": "https://storage.googleapis.com/…",
    "billable_minutes": 1,
    "billing_total_cents": 8,
    "transcripts": [ /* see Transcript format */ ]
  }
}
FieldTypeDescription
call_idintegerStable across every event for this call
directionstringinbound, outbound, mic, widget
from_number, to_numberstringE.164. from_number is "web" for mic/widget
start_time, end_timetimestampISO 8601 UTC
duration_secondsintegerDerived
statusstringcompleted or failed
end_reasonstringSee table below
product, voicestringAgent config in effect at call time
transfer_numberstring | nullIf end_reason = "transfer"
recording_urlstring | nullExpiring signed URL; download promptly. null for sessions with no recording
billable_minutesintegerRounded-up minutes
billing_total_centsintegerUSD cents
transcriptsarrayPer-turn transcript — see next section

End reasons

ValueMeaning
caller_hangupRemote party hung up first
agent_endAI ended the call deliberately
transferCall was transferred; transfer_number is set
timeoutSilence / inactivity timeout
errorUpstream failure

Transcript format

[
  {
    "role": "user",
    "content_type": "text/plain",
    "content": "Hi, I'm calling about my appointment.",
    "start_ms": 1200,
    "end_ms":   4100
  },
  {
    "role": "assistant",
    "content_type": "text/plain",
    "content": "Sure, what date works best?",
    "start_ms": 4200,
    "end_ms":   6100
  },
  {
    "role": "tool_call",
    "content_type": "application/json",
    "content": {
      "name": "search_appointments",
      "arguments": { "date": "2026-04-21" }
    },
    "start_ms": 6200,
    "end_ms":   6200
  },
  {
    "role": "tool_response",
    "content_type": "application/json",
    "content": { "available_slots": ["9:00 AM", "2:00 PM"] },
    "start_ms": 6210,
    "end_ms":   6210
  }
]
FieldTypeDescription
rolestringuser, assistant, tool_call, or tool_response
content_typestringtext/plain for speech; application/json for tools
contentstring | object
start_ms, end_msintegerOffsets from call start, ms. Tool rows may have equal start/end
For the fully structured turn history (with interruption markers, ack-prompts, and first-byte latencies), use GET /v1/calls/{call_id}/history.

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 ("call.complete", "telephony.complete", "web.complete"):
        data = event["data"]
        await persist_call_record(
            call_id=data["call_id"],
            from_number=data["from_number"],
            transcripts=data["transcripts"],
            recording_url=data["recording_url"],
        )
        if data["end_reason"] == "transfer":
            await notify_team(data["transfer_number"], data["call_id"])
    return {"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 / timeout / error.

call.incoming

The blocking counterpart that runs at call start.

Events catalog

Other event types you can subscribe to.

Call history API

Same data accessible via REST for backfill / replay.