Open in
telephony.complete / web.complete
Niet-blokkerende webhook die wordt verzonden wanneer een oproep eindigt, met transcriptie, opname-URL en statistieken.
Een voltooiingsevent wordt geactiveerd nadat elk gesprek is beëindigd — inkomende telefonie, uitgaande telefonie, webgesprek of testgesprek (microfoonsessie in de builder). Het is niet-blokkerend: reageer met een willekeurige 2xx.
Het event wordt via beide paden geleverd:
- Webhook-eindpunten ontvangen
telephony.complete(telefoongesprekken) ofweb.complete(webgesprekken en microfoontestgesprekken in de builder) met de stabiele payload die hieronder wordt gedocumenteerd, eenevent_idper levering, een time-out van 30 s en opnieuw proberen gedurende maximaal 24 h. - De verouderde webhook met één URL ontvangt één synchrone poging (time-out van 10 s, geen nieuwe pogingen) met een iets andere payload — zie Verschillen in verouderde payloads.
Aanvraagpayload (endpointleveringen)
{
"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"
}| Veld | Type | Beschrijving |
|---|---|---|
call_id | integer | Stabiel in elke gebeurtenis voor deze oproep |
agent_id | integer | null | Agent die de oproep heeft afgehandeld, wanneer er een was toegewezen |
agent_name | string | null | Agent die de oproep heeft afgehandeld, wanneer er een was toegewezen |
direction | string | inbound, outbound, web, test. Historische payloads kunnen verouderde waarden mic of widget bevatten |
from_number, to_number | string | E.164. from_number is de letterlijke waarde "web" voor weboproepen en testoproepen |
origin_domain | string | Alleen web/test — de pagina-origin waarop de widget werd gehost (leeg voor mic-sessies) |
start_time, end_time | timestamp | ISO 8601 UTC |
duration_seconds | integer | null | Afgeleid van start/einde |
status | string | completed of failed |
end_reason | string | Zie de onderstaande tabel |
product, voice | string | Agentconfiguratie die van kracht was op het moment van de oproep |
variables | object | Invoervariabelen waarvan een momentopname is gemaakt toen de oproep begon |
unresolved_variables | array | Variabelenamen waarnaar de oproepconfiguratie verwijst maar die niet bij het begin van de oproep zijn opgegeven |
transfer_number | string | null | Ingesteld wanneer de oproep is doorgeschakeld |
recording_url | string | null | Verlopende ondertekende URL; download deze snel. null wanneer geen opname-artefact beschikbaar is |
recording_stopped_at | timestamp | null | Wanneer de opname werd gestopt voordat de oproep eindigde; niets na dit moment staat in de opname. null wanneer de opname tot het einde van de oproep liep of de oproep niet werd opgenomen |
recording_stopped_reason | string | null | Waarom de opname vroegtijdig stopte. warm_transfer: deze stopt vlak voordat een warme doorverbinding de bestemming belt, en blijft uitgeschakeld, zelfs als de doorverbinding mislukt en de beller terugkeert naar de agent. Behandel andere waarden als informatief; er kunnen meer waarden worden toegevoegd. null wanneer recording_stopped_at null is |
billable_minutes | number | Gefactureerde minuten, afgerond op het dichtstbijzijnde kwartier minuut (stappen van 15 seconden, minimaal 0.25). Oproepen die rechtstreeks naar voicemail gaan rapporteren hier nog steeds hun werkelijke gemeten minuten, maar de kosten zijn gemaximeerd op één minuut tegen het paktettarief. |
billing_total_cents | integer | USD-centen |
transcripts | array | Transcriptitems per beurt; kan leeg zijn wanneer een transcript niet beschikbaar is |
summary | string | null | Samenvatting na de oproep in gewone taal (twee tot vier zinnen proza, maximaal 2.000 tekens, geschreven in de taal van de oproep: de primaire taal van de agent, tenzij de oproep deze overschreef) wanneer de agent Een oproepsamenvatting genereren heeft ingeschakeld onder Te verzamelen gegevens in de agentbouwer (call_summary_enabled op de Agent) en het genereren is geslaagd; anders null, ook voor oproepen die te kort zijn om samen te vatten, oproepen zonder transcript en agents waarvoor opname is uitgeschakeld. De samenvatting wordt gegenereerd in dezelfde stap na de oproep als extractie, dus de instelling Wachten op geëxtraheerde gegevens voordat de webhook voor een voltooide oproep wordt verzonden (extraction_blocks_completion) bepaalt of deze gebeurtenis erop wacht: in een wachtmodus is de samenvatting gereed voordat deze gebeurtenis wordt verzonden (de samenvatting zelf krijgt maximaal 30 seconden binnen het budget van 75 seconden van de stap); wanneer de gebeurtenis niet wacht (never, of een doorverbinding onder except_transfers) is dit veld null en wordt de samenvatting niet opnieuw verzonden in call.data_extracted, dus lees deze later uit summary op het Oproepobject. Ook beschikbaar op de oproeppagina in het dashboard |
extracted_data | object | null | Gestructureerd extractieresultaat met status, fields, evidence, verification, field_reasons en schema_version. Elk niet-nullveld bevat het exacte structureel gecontroleerde citaat (maximaal 1.000 tekens), plus de sprekersrol en beurtindex; langere door het model geretourneerde citaten worden afgewezen in plaats van afgekapt. Bewijs is null wanneer het veld null is. verified betekent dat elke kandidaat precies één geldig onafhankelijk oordeel heeft ontvangen. unavailable omvat ook onjuist gevormde of gedeeltelijke uitvoer van de verificateur; geldige gedeeltelijke oordelen blijven van toepassing, terwijl kandidaten zonder één geldig oordeel op null worden gezet. status is completed, failed, exhausted, skipped of skipped_recording_disabled; null wanneer de agent geen extractievelden had |
steps_trace | array | Alleen voor oproepen waarvan de agent stappen of overdrachten heeft. De volgorde waarin de oproep deze doorliep: elk item bevat t_ms (milliseconden sinds de oproep begon), agent (de sleutel van de agent, a plus de ID), step (de stap-ID, of null bij een overdracht naar een agent zonder stappen) en via (start, model, condition, on_enter of handoff). Bevat alleen ID's en tijden, dus blijft bewaard, ook wanneer opname is uitgeschakeld |
collected_values | object | Alleen voor oproepen waarvan de agent stappen of overdrachten heeft. De waarden die de agent in zijn stappen heeft verzameld, ingedeeld op naam van het verzamelveld, plus <tool>_result voor het on_enter_tool-resultaat van elke stap. Weggelaten wanneer de inhoud van de oproep niet wordt bewaard (opname uitgeschakeld) |
Eindredenen
| Waarde | Betekenis |
|---|---|
user_hangup | Externe partij heeft als eerste opgehangen |
ai_hangup | AI heeft het gesprek bewust beëindigd |
ai_transfer | AI heeft het gesprek doorverbonden; transfer_number is ingesteld |
ai_warm_transfer | AI heeft een warme (begeleide) doorverbinding voltooid |
voicemail_hangup | Voicemail is gedetecteerd en het gesprek is beëindigd volgens je voicemail_action |
max_duration | Het gesprek heeft de maximale duur bereikt: de limiet van de agent, de limiet van het gesprek zelf of de platformlimiet van 6 uur. Een gesprek dat de AI of de beller na de waarschuwing voor de tijdslimiet beëindigt, behoudt ai_hangup of user_hangup |
superseded | De sessie is vervangen door een nieuwere |
inbound_config_webhook_failed | Inkomend gesprek naar een nummer in webhookmodus: de configuratiewebhook heeft geen bruikbare configuratie geretourneerd, waardoor het gesprek is beëindigd voordat een agent opnam. status is failed en het gesprek wordt niet gefactureerd |
unknown | De reden voor beëindiging kon niet worden vastgesteld |
Transcriptindeling
Elke invoer in transcripts is één gespreksbeurt. Rollen zijn
user (spraak van de beller), model (spraak van de agent en toolaanroepen),
tool (toolresultaten) en system (oproepgebeurtenissen zoals taalwisselingen).
[
{
"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"] }
}
}
]| Veld | Type | Beschrijving |
|---|---|---|
role | string | user, model, tool of system |
content_type | string | text/plain voor spraak; application/json voor toolaanroepen, toolresultaten en systeemgebeurtenissen |
content | string | object | Spraaktekst of het hierboven weergegeven gestructureerde object. Toolaanroepen: {"tool_call": name, "arguments": {…}}. Toolresultaten: {"tool_name": name, "response": {…}} |
start_ms, end_ms | integer | Verschuivingen vanaf het begin van de oproep, in ms. Aanwezig wanneer audiotiming bekend is |
ttfa_ms | integer | Tijd tot de eerste audio voor een model-beurt, indien gemeten |
audio_url, audio_urls | string / array | Verlopende ondertekende URL's voor de audio van de beurt, indien per beurt opgenomen |
Gebruik voor de volledig gestructureerde beurtgeschiedenis (met onderbrekingsmarkeringen,
bevestigingsprompts en onbewerkte posities)
GET /v1/calls/{call_id}/history.
Verschillen met de legacy-payload
De legacy webhook-envelope met één URL is
{"type": "telephony.complete" | "web.complete", "data": {…}} met
geen event_id, en de data wijkt af van de endpoint-payload:
De legacy voltooiingspayload bevat ook agent_id en agent_name.
- De beurtarray staat onder
history, niet ondertranscripts(hetzelfde beurtenschema als hierboven). - De veldenverzameling is het onbewerkte eindrapport van de oproep en kan aanvullende interne velden bevatten naast de bovenstaande tabel — behandel onbekende velden als informatief.
- Weboproepen (
direction: "web") latenfrom_number/to_numberweg en voegenorigin_domaintoe. - Mic-testoproepen in de builder worden op het legacy-pad gerapporteerd als
telephony.complete(het endpointsysteem wijst ze toe aanweb.complete). - Overdrachtscoördinatie: wanneer een oproep eindigt met een overdracht, wordt de
legacy webhook synchroon aangeroepen en kan deze
{"transfer_ready": false}retourneren om aan te geven dat het overdrachtsdoel nog niet gereed is. Elke andere respons (of geen legacy webhook) laat de overdracht doorgaan. Endpoint-leveringen worden hiervoor nooit geraadpleegd.
Voorbeeldhandler
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 });
},
);Veelvoorkomende toepassingen
Sla het transcript en de opname-URL van elke oproep op naast je klantgegevens.
Stream transcripties naar een pijplijn voor onderwerpenmodellering, extractie van CSAT-signalen of monitoring van overdrachtspercentages.
Open oproepen in een QA-tool voor menselijke beoordeling, of voer ze uit met je eigen evaluatiemodel.
Activeer een menselijk teamlid bij overdracht / storing.