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

Developer cookbook

통화별 동적 구성

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

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

작동 방식

  1. telephony.incoming (전화) 또는 web.incoming (위젯) 이벤트를 구독합니다. 둘 다 차단형 웹훅입니다. ThunderPhone은 통화를 계속하기 전에 응답을 기다리며, 전화 통화의 경우 최대 13초 (재시도 1회 포함), 위젯 세션의 경우 최대 10초 동안 기다립니다.
  2. ThunderPhone이 {call_id, from_number, to_number}를 전송합니다(위젯 세션은 번호 대신 위젯별 필드를 포함합니다. 요청 스키마를 참조하세요).
  3. 서버가 에이전트 구성(프롬프트, 음성, 제품, 도구)으로 응답합니다. ThunderPhone은 해당 구성을 통화에 사용합니다.
  4. 대체 에이전트는 없습니다. {} 또는 잘못된 구성을 반환하거나, 재시도 후에도 요청이 실패하면 전화 통화는 즉시 종료되고 end_reason inbound_config_webhook_failed로 기록됩니다. 위젯 세션 요청은 실패합니다.

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초에, 전체 교환은 13초에 종료되며, 매초가 발신자에게는 무음 시간입니다. 필요하면 데이터베이스를 조회하되, 후속 LLM을 동기적으로 호출하지 마세요. 동적 프롬프트 생성이 필요하다면 미리 계산하고 캐시하세요.
  • 정상적으로 대체 처리합니다. 예상하지 못한 상태에서는 기본 prompt와 voice(또는 저장된 agent_id)로 응답하세요. {} 또는 오류 상태를 반환하면 통화가 종료됩니다.
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배열인라인 함수 도구 스키마 — 함수 도구를 참조하세요

저장된 에이전트를 유지하고 변수 제공

조직의 저장된 에이전트를 통화별 데이터와 함께 사용하려면 {"agent_id": 12, "variables": {"name": "Ada"}}를 반환합니다. 프롬프트에는 {{name}} 또는 {{name|Friend}}를 포함할 수 있습니다. 웹훅 변수는 요청 수준 변수보다 우선하여 병합됩니다. null은 자리 표시자의 기본값을 사용하며, 기본값이 없으면 빈 텍스트를 사용합니다. 최종 값과 확인되지 않은 이름은 통화 세부 정보 및 완료 웹훅에 표시됩니다. 저장된 에이전트 응답은 agent_id, variables, timezone만 허용합니다. prompt가 있으면 응답은 인라인 구성을 사용하고 agent_id를 무시합니다(null 또는 정수가 아닌 메타데이터 포함). 인라인 프롬프트는 여전히 유효해야 합니다. 인라인 구성 응답에는 variables도 포함할 수 있습니다. 저장된 에이전트 응답은 전화 및 위젯 통화 모두에서 에이전트에 배포된 A/B 분할을 사용한 후 변수를 렌더링합니다. 제한 사항 및 세션 API 지원은 통화 변수를 참조합니다. 차단 구성은 레거시 전화번호/조직 URL 또는 웹훅 모드 위젯 키에서 제공됩니다. 엔드포인트 시스템 수신 이벤트는 알림 전용입니다.

통화별 시간대 설정

하나의 에이전트가 서로 다른 시간대의 연락처에 응답할 수 있습니다. 저장된 에이전트 또는 인라인 응답에 IANA 이름인 timezone을 추가하면, 에이전트는 이 통화에서 에이전트 자체 시간대보다 해당 시간대의 날짜와 시간을 먼저 안내하고 가능한 시간을 제안합니다.

{"agent_id": 12, "timezone": "America/Chicago", "variables": {"name": "Ada"}}

variables에는 타임스탬프 대신 시간대를 전송합니다. 통화 중에도 에이전트의 시계는 계속 흐르므로 전달한 시간은 오래되어 정확하지 않게 됩니다. IANA 시간대가 아닌 값은 무시되며, 통화는 에이전트의 시간대로 연결됩니다.

패턴

로그인한 사용자 컨텍스트

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

A/B 프롬프트 출시

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

그래도 웹훅 측 제어가 필요하다면 call_id를 해시하여 버킷을 만드세요. 0..49에는 프롬프트 A를 제공하고 50..99에는 프롬프트 B를 제공합니다. 선택한 버킷을 자체 DB에 기록하고, 나중에 완료된 통화의 등급과 연관 지으세요.

시간 기반 라우팅

영업시간에는 "실시간 지원" 에이전트를 사용하고, 영업시간 이후에는 "메시지 접수" 에이전트를 사용합니다. 핸들러에서 new Date().getUTCHours()를 기준으로 간단히 전환합니다.


다음 단계