View as Markdown
Open in
telephony.complete / web.complete
통화가 종료될 때 트랜스크립트, 녹음 URL 및 지표와 함께 전달되는 비차단 웹훅입니다.
모든 통화가 종료된 후 완료 이벤트가 발생합니다. 수신 전화, 발신 전화, 웹 통화 또는 테스트 통화(빌더 마이크 세션)가 해당합니다. 이는 차단하지 않습니다. 모든 2xx로 응답합니다.
이벤트는 두 경로 모두로 전달됩니다.
- **Webhook 엔드포인트**는 아래에 문서화된 안정적인 페이로드,
전송별
event_id, 30초 타임아웃 및 최대 24시간 재시도와 함께telephony.complete(전화 통화) 또는web.complete(웹 통화 및 빌더 마이크 테스트 통화)를 수신합니다. - **레거시 단일 URL webhook**은 약간 다른 페이로드로 동기식 시도 1회(10초 타임아웃, 재시도 없음)를 수신합니다. 자세한 내용은 레거시 페이로드 차이점을 참조합니다.
요청 페이로드(엔드포인트 전송)
{
"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"
}| 필드 | 유형 | 설명 |
|---|---|---|
call_id | integer | 이 통화의 모든 이벤트에서 동일하게 유지됩니다 |
agent_id | integer | null | 할당된 경우 통화를 처리한 에이전트입니다 |
agent_name | string | null | 할당된 경우 통화를 처리한 에이전트입니다 |
direction | string | inbound, outbound, web, test입니다. 과거 페이로드에는 레거시 mic 또는 widget 값이 포함될 수 있습니다 |
from_number, to_number | string | E.164입니다. 웹 통화 및 테스트 통화에서 from_number는 리터럴 "web"입니다 |
origin_domain | string | 웹/테스트 전용 — 위젯을 호스팅한 페이지의 출처입니다(mic 세션에서는 비어 있음) |
start_time, end_time | timestamp | ISO 8601 UTC입니다 |
duration_seconds | integer | null | 시작/종료 시간에서 파생됩니다 |
status | string | completed 또는 failed입니다 |
end_reason | string | 아래 표를 참조하세요 |
product, voice | string | 통화 시점에 적용된 에이전트 구성입니다 |
variables | object | 통화가 시작될 때 스냅샷된 입력 변수입니다 |
unresolved_variables | array | 통화 구성에서 참조되었지만 통화 시작 시 제공되지 않은 변수 이름입니다 |
transfer_number | string | null | 통화가 전환된 경우 설정됩니다 |
recording_url | string | null | 만료되는 서명 URL입니다. 즉시 다운로드하세요. 녹음 아티팩트를 사용할 수 없는 경우 null입니다 |
recording_stopped_at | timestamp | null | 통화가 끝나기 전에 녹음이 중지된 시점입니다. 이 시점 이후의 내용은 녹음에 포함되지 않습니다. 녹음이 통화 종료까지 진행되었거나 통화가 녹음되지 않은 경우 null입니다 |
recording_stopped_reason | string | null | 녹음이 일찍 중지된 이유입니다. warm_transfer: 상담원 연결 전환이 대상에 전화를 걸기 직전에 녹음이 중지되며, 전환에 실패하고 발신자가 에이전트로 돌아와도 계속 중지 상태로 유지됩니다. 다른 값은 정보용으로 처리하세요. 추가될 수 있습니다. recording_stopped_at가 null인 경우 null입니다 |
billable_minutes | number | 청구되는 분 단위 시간으로, 가장 가까운 15초 단위(최소 0.25분)로 반올림됩니다. 바로 음성사서함으로 연결된 통화도 실제 계량된 분 단위를 여기에서 보고하지만, 요금은 플랜 요율 기준으로 최대 1분으로 제한됩니다. |
billing_total_cents | integer | USD 센트입니다 |
transcripts | array | 턴별 대화 기록 항목입니다. 대화 기록을 사용할 수 없는 경우 비어 있을 수 있습니다 |
summary | string | null | 에이전트 빌더의 수집할 데이터에서 에이전트의 통화 요약 생성이 켜져 있고(Agent의 call_summary_enabled) 생성에 성공한 경우의 일반 언어 통화 후 요약입니다. 두 문장에서 네 문장으로 구성되며 최대 2,000자이고, 통화의 언어(통화에서 재정의하지 않은 경우 에이전트의 기본 언어)로 작성됩니다. 그렇지 않은 경우 null이며, 요약하기에 너무 짧은 통화, 대화 기록이 없는 통화, 녹음이 꺼진 에이전트의 통화도 포함됩니다. 요약은 추출과 동일한 통화 후 단계에서 생성되므로, 통화 완료 웹훅을 전송하기 전에 추출된 데이터 대기 설정(extraction_blocks_completion)이 이 이벤트가 요약을 기다릴지 결정합니다. 대기 모드에서는 이 이벤트가 전송되기 전에 요약이 준비됩니다(요약 자체에는 단계의 75초 예산 내에서 최대 30초가 주어짐). 이벤트가 대기하지 않는 경우(never 또는 except_transfers의 전환) 이 필드는 null이며 요약은 call.data_extracted에서 다시 전송되지 않으므로, 나중에 Call 객체의 summary에서 읽으세요. 대시보드의 통화 페이지에서도 사용할 수 있습니다 |
extracted_data | object | null | status, fields, evidence, verification, field_reasons, schema_version을 포함하는 구조화된 추출 결과입니다. null이 아닌 각 필드에는 구조적으로 정확성이 확인된 인용문(최대 1,000자)과 화자 역할 및 턴 인덱스가 포함됩니다. 모델이 반환한 더 긴 인용문은 잘리지 않고 거부됩니다. 필드가 null인 경우 증거는 null입니다. verified는 모든 후보가 유효한 독립 판정을 정확히 하나씩 받았음을 의미합니다. unavailable은 형식이 잘못되었거나 불완전한 검증기 출력도 포함합니다. 유효한 부분 판정은 계속 적용되지만, 유효한 판정이 하나도 없는 후보는 null 처리됩니다. status는 completed, failed, exhausted, skipped 또는 skipped_recording_disabled이며, 에이전트에 추출 필드가 없었던 경우 null입니다 |
steps_trace | array | 에이전트에 단계 또는 핸드오프가 있는 통화에만 포함됩니다. 통화가 이를 거친 순서입니다. 각 항목에는 t_ms(통화 시작 이후의 밀리초), agent(에이전트 키, a와 해당 ID의 조합), step(단계 ID 또는 단계가 없는 에이전트로 핸드오프한 경우 null) 및 via(start, model, condition, on_enter 또는 handoff)가 있습니다. ID와 시간만 보유하므로 녹음이 꺼져 있어도 유지됩니다 |
collected_values | object | 에이전트에 단계 또는 핸드오프가 있는 통화에만 포함됩니다. 수집 필드 이름을 키로 하여 에이전트가 단계에서 수집한 값과 각 단계의 on_enter_tool 결과에 대한 <tool>_result를 포함합니다. 통화 콘텐츠가 보존되지 않는 경우(녹음이 꺼진 경우) 생략됩니다 |
종료 사유
| 값 | 의미 |
|---|---|
user_hangup | 상대방이 먼저 전화를 끊었습니다 |
ai_hangup | AI가 의도적으로 통화를 종료했습니다 |
ai_transfer | AI가 통화를 전달했으며 transfer_number가 설정되어 있습니다 |
ai_warm_transfer | AI가 웜 전송(상담원 연결 전 확인)을 완료했습니다 |
voicemail_hangup | 음성 사서함이 감지되었으며 voicemail_action에 따라 통화가 종료되었습니다 |
max_duration | 통화가 최대 통화 시간 제한에 도달했습니다. 에이전트의 제한, 통화 자체의 제한 또는 플랫폼의 6시간 제한일 수 있습니다. 시간 제한 경고 후 AI 또는 발신자가 통화를 종료한 경우 ai_hangup 또는 user_hangup이 유지됩니다 |
superseded | 세션이 더 새로운 세션으로 대체되었습니다 |
inbound_config_webhook_failed | 웹훅 모드의 번호로 걸려 온 수신 통화에서 구성 웹훅이(가) 사용할 수 있는 구성을 반환하지 않았습니다. 따라서 에이전트가 응답하기 전에 통화가 종료되었습니다. status는 failed이며 통화 요금이 청구되지 않습니다 |
unknown | 종료 사유를 확인할 수 없습니다 |
트랜스크립트 형식
transcripts의 각 항목은 하나의 대화 턴입니다. 역할은
user(발신자 음성), model(에이전트 음성 및 도구 호출),
tool(도구 결과), system(언어 전환 등의 통화 이벤트)입니다.
[
{
"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"] }
}
}
]| 필드 | 유형 | 설명 |
|---|---|---|
role | 문자열 | user, model, tool 또는 system |
content_type | 문자열 | 음성에는 text/plain, 도구 호출, 도구 결과 및 시스템 이벤트에는 application/json |
content | 문자열 | 객체 | 음성 텍스트 또는 위에 표시된 구조화된 객체입니다. 도구 호출: {"tool_call": name, "arguments": {…}}. 도구 결과: {"tool_name": name, "response": {…}} |
start_ms, end_ms | 정수 | 통화 시작 시점부터의 오프셋(밀리초)입니다. 오디오 타이밍을 알 수 있는 경우 포함됩니다 |
ttfa_ms | 정수 | 측정된 경우 model 턴의 첫 오디오까지 걸린 시간입니다 |
audio_url, audio_urls | 문자열 / 배열 | 턴별로 녹음된 경우 해당 턴 오디오의 만료되는 서명 URL입니다 |
중단 마커, 맞장구 프롬프트 및 원시 위치를 포함한 완전한 구조의 턴 기록은
GET /v1/calls/{call_id}/history를 사용합니다.
레거시 페이로드 차이점
레거시 단일 URL 웹훅 엔벨로프는
{"type": "telephony.complete" | "web.complete", "data": {…}}이며,
event_id가 없고 data가 엔드포인트 페이로드와 다릅니다.
레거시 완료 페이로드에는 agent_id 및 agent_name도 포함됩니다.
- 턴 배열은
transcripts가 아닌history아래에 있습니다(위와 동일한 턴 스키마). - 필드 세트는 원시 통화 종료 보고서이며 위 표에 없는 추가 내부 필드를 포함할 수 있습니다. 알 수 없는 필드는 정보용으로 처리합니다.
- 웹 통화(
direction: "web")는from_number/to_number를 생략하고origin_domain을 추가합니다. - 빌더 마이크 테스트 통화는 레거시 경로에서
telephony.complete로 보고됩니다(엔드포인트 시스템에서는 이를web.complete로 매핑합니다). - 전환 조정: 통화가 전환 중 종료되면 레거시 웹훅이 동기적으로 호출되며, 인계 대상이 준비되지 않았음을 알리기 위해
{"transfer_ready": false}를 응답할 수 있습니다. 그 외 모든 응답(또는 레거시 웹훅 없음)은 전환이 진행되도록 합니다. 이 경우 엔드포인트 전송은 참조하지 않습니다.
핸들러 예시
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 });
},
);일반적인 사용 사례
CRM 통합
각 통화의 트랜스크립트와 녹음 URL을 고객 기록과 함께 저장합니다.
분석
주제 모델링, CSAT 신호 추출 또는 전환율 모니터링을 위해 트랜스크립트를 파이프라인으로 스트리밍합니다.
품질 검토
사람이 검토할 수 있도록 QA 도구에서 통화를 열거나 자체 평가 모델로 실행합니다.
알림
전환 또는 실패 시 담당 팀원에게 알림을 보냅니다.