ThunderPhone 2.0을 출시했습니다.별도 문의 없이 분당 2¢부터.출시 소식 보기

Developer cookbook

통화별 동적 구성

직접 제어하는 웹훅의 맞춤 로직을 기반으로, 수신 통화마다 응답 에이전트를 선택하거나 프롬프트와 설정을 별도로 다시 구성합니다.

기본적으로 모든 전화번호와 공개 키에는 정적 에이전트가 할당되어 있습니다. 발신자별 또는 방문자별 맞춤 설정이 필요한 경우 — VIP 라우팅, 로그인한 사용자 컨텍스트, A/B 프롬프트 테스트 — 웹훅 모드로 전환하고 서버가 결정하도록 하십시오.

작동 방식

  1. telephony.incoming (전화) 또는 web.incoming (위젯) 이벤트를 구독합니다. 둘 다 블로킹 웹훅입니다. ThunderPhone은 통화를 계속하기 전에 응답을 최대 10초 동안 기다립니다.
  2. ThunderPhone이 {call_id, from_number, to_number}를 전송합니다(위젯 세션은 번호 대신 위젯별 필드를 포함합니다. 요청 스키마를 참조하십시오).
  3. 서버가 에이전트 구성(프롬프트, 음성, 제품, 도구)으로 응답합니다. ThunderPhone은 해당 통화에 이 구성을 사용합니다.
  4. {}를 반환하거나, 시간이 초과되거나, 오류가 발생하면 정적으로 할당된 에이전트가 대체 수단으로 사용됩니다. 안전한 기본값입니다.

1. 웹훅 대상 구성

전화번호의 경우 엔드포인트에서 telephony.incoming을 구독하십시오:

curl -X POST https://api.thunderphone.com/v1/developer/webhook-endpoints \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label":  "Prod call-incoming",
    "url":    "https://example.com/thunderphone/incoming",
    "events": ["telephony.incoming"]
  }'

응답에는 일회용 secret이 포함됩니다. 저장해 두십시오. 서명 검증에 사용합니다.

위젯 세션의 경우 엔드포인트 URL이 포함된 mode="webhook"의 공개 키를 생성하십시오:

curl -X POST https://api.thunderphone.com/v1/publishable-key \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name":            "Dynamic widget",
    "mode":            "webhook",
    "webhook_url":     "https://example.com/thunderphone/widget-incoming",
    "allowed_domains": ["example.com"]
  }'

위젯은 세션이 시작될 때마다 이 URL로 POST 요청을 전송합니다.

2. 핸들러 구현

세 가지 실무 원칙:

  • 모든 요청에서 서명을 검증합니다(웹훅 서명 검증 참고). 개발 환경에서도 이를 건너뛰지 마세요. 한 번 올바르게 구현한 뒤 재사용하세요.
  • 빠르게 응답합니다. 10초가 엄격한 제한이며, 매초마다 발신자에게는 무음 시간입니다. 필요한 경우 데이터베이스를 조회하되, 다운스트림 LLM을 동기적으로 호출하지 마세요. 동적 프롬프트 생성이 필요하다면 미리 계산하고 캐시하세요.
  • 깔끔하게 폴백합니다. 예상하지 못한 상태에서는 {}를 반환하여 정적으로 할당된 에이전트가 통화를 처리하도록 해야 합니다.
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, sig: str) -> bool:
    expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig or "")
 
@app.post("/thunderphone/incoming")
async def incoming(request: Request):
    body = await request.body()
    if not verify(body, request.headers.get("X-ThunderPhone-Signature", "")):
        raise HTTPException(401)
 
    event = json.loads(body)
    if event["type"] not in ("telephony.incoming", "web.incoming"):
        return {}  # fall back to default
 
    caller = event["data"]["from_number"]
    # Cheap DB lookup: is this a known VIP?
    customer = lookup_customer(caller)
    if customer and customer.tier == "vip":
        return {
            "prompt":  f"You are a VIP concierge for {customer.name}. Be proactive…",
            "voice":   "john",
            "product": "storm-base",
        }
    return {}  # default agent handles non-VIPs
 
def lookup_customer(phone: str):
    # ... your CRM integration ...
    pass
Express
import crypto from "node:crypto";
import express from "express";
 
const app = express();
const SECRET = process.env.THUNDERPHONE_WEBHOOK_SECRET;
 
function verify(body, sig) {
  const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");
  return sig &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
 
app.post(
  "/thunderphone/incoming",
  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"));
 
    const IMPORTANT_TYPES = new Set([
      "telephony.incoming",
      "web.incoming",
    ]);
    if (!IMPORTANT_TYPES.has(event.type)) return res.json({});
 
    const customer = await lookupCustomer(event.data.from_number);
    if (customer?.tier === "vip") {
      return res.json({
        prompt:  `You are a VIP concierge for ${customer.name}. Be proactive…`,
        voice:   "john",
        product: "storm-base",
      });
    }
    res.json({}); // fall back to default agent
  },
);

3. 응답 스키마

응답 본문은 수신 통화 응답 스키마와 정확히 일치합니다. 자주 사용하는 필드는 다음과 같습니다.

필드유형설명
prompt문자열(필수)에이전트의 시스템 프롬프트
voice문자열(필수)GET /v1/voices의 음성 ID
product문자열기본값은 spark입니다
background_track문자열 | null배경 오디오 ID
acknowledgement_prompt_mode문자열auto 또는 manual(확인 응답이 포함된 Storm 전용)
acknowledgement_prompt문자열모드가 manual인 경우 필수입니다
tools배열인라인 함수 도구 스키마 — 함수 도구 참고

패턴

로그인한 사용자 컨텍스트

웹훅 모드 위젯에서는 방문자의 페이지가 이미 방문자가 누구인지 알고 있습니다. 위젯 SDK가 전달하는 쿼리 문자열 매개변수(?customer_id=123)를 포함해 웹훅을 호출하고 서버 측에서 고객을 조회합니다.

A/B 프롬프트 롤아웃

직접 구현하기 전에 ThunderPhone에는 변형을 정의하고, 트래픽을 분할하며, 변형별 결과를 비교하는 기본 실험 기능 (/dashboard/experiments 및 에이전트 빌더의 A/B 탭)이 있다는 점을 참고하세요. 웹훅은 필요하지 않습니다.

그래도 웹훅 측 제어가 필요한 경우: call_id를 해싱하여 버킷으로 분류하고, 0..49에는 프롬프트 A를, 50..99에는 프롬프트 B를 제공합니다. 선택한 버킷을 자체 DB에 기록한 후 나중에 완료된 통화의 평가와 연관 분석합니다.

시간 기반 라우팅

영업시간 → "실시간 지원" 에이전트, 영업시간 외 → "메시지 접수" 에이전트입니다. 핸들러에서 new Date().getUTCHours()를 기준으로 단순 전환합니다.


다음 단계