ThunderPhone 2.0을 출시했습니다.셀프서비스로, 분당 2¢부터.출시 소식 보기

Webhooks

telephony.complete / web.complete

통화가 종료될 때 트랜스크립트, 녹음 URL 및 지표와 함께 전달되는 비차단 웹훅입니다.

모든 통화가 종료된 후 완료 이벤트가 발생합니다. 수신 전화, 발신 전화, 웹 통화 또는 테스트 통화(빌더 마이크 세션)가 해당합니다. 이는 차단하지 않습니다. 모든 2xx로 응답합니다.

이벤트는 두 경로 모두로 전달됩니다.

요청 페이로드(엔드포인트 전송)

{
  "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_idinteger이 통화의 모든 이벤트에서 동일하게 유지됩니다
agent_idinteger | null할당된 경우 통화를 처리한 에이전트입니다
agent_namestring | null할당된 경우 통화를 처리한 에이전트입니다
directionstringinbound, outbound, web, test입니다. 과거 페이로드에는 레거시 mic 또는 widget 값이 포함될 수 있습니다
from_number, to_numberstringE.164입니다. 웹 통화 및 테스트 통화에서 from_number는 리터럴 "web"입니다
origin_domainstring웹/테스트 전용 — 위젯을 호스팅한 페이지의 출처입니다(mic 세션에서는 비어 있음)
start_time, end_timetimestampISO 8601 UTC입니다
duration_secondsinteger | null시작/종료 시간에서 파생됩니다
statusstringcompleted 또는 failed입니다
end_reasonstring아래 표를 참조하세요
product, voicestring통화 시점에 적용된 에이전트 구성입니다
variablesobject통화가 시작될 때 스냅샷된 입력 변수입니다
unresolved_variablesarray통화 구성에서 참조되었지만 통화 시작 시 제공되지 않은 변수 이름입니다
transfer_numberstring | null통화가 전환된 경우 설정됩니다
recording_urlstring | null만료되는 서명 URL입니다. 즉시 다운로드하세요. 녹음 아티팩트를 사용할 수 없는 경우 null입니다
recording_stopped_attimestamp | null통화가 끝나기 전에 녹음이 중지된 시점입니다. 이 시점 이후의 내용은 녹음에 포함되지 않습니다. 녹음이 통화 종료까지 진행되었거나 통화가 녹음되지 않은 경우 null입니다
recording_stopped_reasonstring | null녹음이 일찍 중지된 이유입니다. warm_transfer: 상담원 연결 전환이 대상에 전화를 걸기 직전에 녹음이 중지되며, 전환에 실패하고 발신자가 에이전트로 돌아와도 계속 중지 상태로 유지됩니다. 다른 값은 정보용으로 처리하세요. 추가될 수 있습니다. recording_stopped_at가 null인 경우 null입니다
billable_minutesnumber청구되는 분 단위 시간으로, 가장 가까운 15초 단위(최소 0.25분)로 반올림됩니다. 바로 음성사서함으로 연결된 통화도 실제 계량된 분 단위를 여기에서 보고하지만, 요금은 플랜 요율 기준으로 최대 1분으로 제한됩니다.
billing_total_centsintegerUSD 센트입니다
transcriptsarray턴별 대화 기록 항목입니다. 대화 기록을 사용할 수 없는 경우 비어 있을 수 있습니다
summarystring | null에이전트 빌더의 수집할 데이터에서 에이전트의 통화 요약 생성이 켜져 있고(Agent의 call_summary_enabled) 생성에 성공한 경우의 일반 언어 통화 후 요약입니다. 두 문장에서 네 문장으로 구성되며 최대 2,000자이고, 통화의 언어(통화에서 재정의하지 않은 경우 에이전트의 기본 언어)로 작성됩니다. 그렇지 않은 경우 null이며, 요약하기에 너무 짧은 통화, 대화 기록이 없는 통화, 녹음이 꺼진 에이전트의 통화도 포함됩니다. 요약은 추출과 동일한 통화 후 단계에서 생성되므로, 통화 완료 웹훅을 전송하기 전에 추출된 데이터 대기 설정(extraction_blocks_completion)이 이 이벤트가 요약을 기다릴지 결정합니다. 대기 모드에서는 이 이벤트가 전송되기 전에 요약이 준비됩니다(요약 자체에는 단계의 75초 예산 내에서 최대 30초가 주어짐). 이벤트가 대기하지 않는 경우(never 또는 except_transfers의 전환) 이 필드는 null이며 요약은 call.data_extracted에서 다시 전송되지 않으므로, 나중에 Call 객체의 summary에서 읽으세요. 대시보드의 통화 페이지에서도 사용할 수 있습니다
extracted_dataobject | nullstatus, 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_tracearray에이전트에 단계 또는 핸드오프가 있는 통화에만 포함됩니다. 통화가 이를 거친 순서입니다. 각 항목에는 t_ms(통화 시작 이후의 밀리초), agent(에이전트 키, a와 해당 ID의 조합), step(단계 ID 또는 단계가 없는 에이전트로 핸드오프한 경우 null) 및 via(start, model, condition, on_enter 또는 handoff)가 있습니다. ID와 시간만 보유하므로 녹음이 꺼져 있어도 유지됩니다
collected_valuesobject에이전트에 단계 또는 핸드오프가 있는 통화에만 포함됩니다. 수집 필드 이름을 키로 하여 에이전트가 단계에서 수집한 값과 각 단계의 on_enter_tool 결과에 대한 <tool>_result를 포함합니다. 통화 콘텐츠가 보존되지 않는 경우(녹음이 꺼진 경우) 생략됩니다

종료 사유

값의미
user_hangup상대방이 먼저 전화를 끊었습니다
ai_hangupAI가 의도적으로 통화를 종료했습니다
ai_transferAI가 통화를 전달했으며 transfer_number가 설정되어 있습니다
ai_warm_transferAI가 웜 전송(상담원 연결 전 확인)을 완료했습니다
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}를 응답할 수 있습니다. 그 외 모든 응답(또는 레거시 웹훅 없음)은 전환이 진행되도록 합니다. 이 경우 엔드포인트 전송은 참조하지 않습니다.

핸들러 예시

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 });
  },
);

일반적인 사용 사례

CRM 통합

각 통화의 트랜스크립트와 녹음 URL을 고객 기록과 함께 저장합니다.

분석

주제 모델링, CSAT 신호 추출 또는 전환율 모니터링을 위해 트랜스크립트를 파이프라인으로 스트리밍합니다.

품질 검토

사람이 검토할 수 있도록 QA 도구에서 통화를 열거나 자체 평가 모델로 실행합니다.

알림

전환 또는 실패 시 담당 팀원에게 알림을 보냅니다.


관련 항목