← 블로그 목록
가이드2026-06-01

070 인바운드 webhook 라우팅 — 시간·번호·DTMF 기준 동적 분기

070 인바운드 webhook 라우팅 — 시간·번호·DTMF 기준 동적 분기

같은 070 번호로 들어오는 통화도 시간대(낮/밤)·발신자(VIP/일반)·DTMF 응답(1=예약/2=문의/3=결제) 기준으로 다른 시나리오로 분기해야 한다. ClawOps webhook 으로 통화 시작 직전에 라우팅 결정.

0. 사전 준비

  • ClawOps 070 발급 완료
  • HTTPS 가능한 webhook endpoint (Vercel/Fly/Render OK)

1. webhook 등록

client.numbers.update(
    "07052358010",
    webhook_url="https://your-server.com/voice-route",
)

또는 Console UI 에서:

Number → Settings → Webhook URL → https://your-server.com/voice-route

2. webhook 페이로드 (인바운드 콜)

ClawOps 가 수신전화 시 application/x-www-form-urlencoded 로 POST 한다. 주요 파라미터:

POST /voice-route
Content-Type: application/x-www-form-urlencoded
X-Signature: <서명키 등록 시 포함>

CallId=CA1a2b3c...&AccountId=AC...&From=01012345678&To=07052358010&CallStatus=in-progress&Direction=inbound
파라미터예시설명
CallIdCA1a2b...통화 고유 ID
AccountIdAC...계정 ID
From01012345678발신 번호
To07052358010수신 ClawOps 070
CallStatusin-progress통화 상태
Directioninbound방향

당신의 endpoint 는 Content-Type: application/xmlVoiceML XML 을 응답한다. 루트는 <Response>. AI 로 응대하려면 <Connect><Stream> 으로 실시간 양방향 오디오를 당신의 WebSocket(STT/LLM/TTS)에 연결한다:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">안녕하세요, ABC 회사입니다.</Say>
  <Connect>
    <Stream url="wss://your-server.com/media"/>
  </Connect>
</Response>

3. 라우팅 패턴들

A. 영업시간 기준 분기

영업중에는 <Connect><Stream> 으로 AI 가 받고, 야간에는 <Say> 안내 후 <Record> 로 메모를 받는다.

from flask import Flask, request, Response
import datetime
from zoneinfo import ZoneInfo

app = Flask(__name__)

@app.route("/voice-route", methods=["POST"])
def voice_route():
    now = datetime.datetime.now(ZoneInfo("Asia/Seoul"))
    is_business_hours = 9 <= now.hour < 18 and now.weekday() < 5

    if is_business_hours:
        # 영업중 — AI 가 실시간 응대 (WebSocket 으로 STT/LLM/TTS)
        xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">안녕하세요, ABC 회사입니다. 어떤 도움이 필요하신가요?</Say>
  <Connect>
    <Stream url="wss://your-server.com/media"/>
  </Connect>
</Response>"""
    else:
        # 야간 — 안내 후 메모 녹음
        xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">안녕하세요, ABC 회사 야간 응대입니다. 지금은 영업시간이 아니라 메모만 받겠습니다. 성함과 용건을 말씀해주시면 영업일 첫 시간에 연락드립니다.</Say>
  <Record maxLength="120" playBeep="true" action="https://your-server.com/voicemail"/>
</Response>"""

    return Response(xml, mimetype="application/xml")

B. 발신자 번호 화이트리스트 (VIP)

VIP 발신자는 <Dial><Number> 로 담당자에게 바로 연결, 그 외는 AI 응대.

VIP_NUMBERS = {"01011112222", "01033334444"}

@app.route("/voice-route", methods=["POST"])
def voice_route():
    caller = request.form["From"]
    if caller in VIP_NUMBERS:
        # VIP 담당자에게 전환
        xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Dial timeout="20" action="https://your-server.com/dial-status">
    <Number>07012340002</Number>
  </Dial>
</Response>"""
    else:
        # 일반 — AI 응대
        xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">안녕하세요, ABC 회사입니다.</Say>
  <Connect>
    <Stream url="wss://your-server.com/media"/>
  </Connect>
</Response>"""
    return Response(xml, mimetype="application/xml")

C. DTMF 응답 기반 메뉴

<Gather> 로 DTMF 를 수집하고, action URL 이 받는 Digits 파라미터로 분기한다.

@app.route("/voice-route", methods=["POST"])
def voice_route():
    # 첫 응답 — 메뉴 안내 + DTMF 수집
    xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather numDigits="1" timeout="5" action="https://your-server.com/voice-dtmf">
    <Say language="ko">예약은 1번, 결제 문의는 2번, 상담사 연결은 0번을 눌러주세요.</Say>
  </Gather>
</Response>"""
    return Response(xml, mimetype="application/xml")

@app.route("/voice-dtmf", methods=["POST"])     # Gather action — Digits 파라미터로 받음
def voice_dtmf():
    digit = request.form.get("Digits", "")

    if digit == "1":
        xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">예약 안내를 시작합니다. 날짜와 인원을 말씀해주세요.</Say>
  <Connect>
    <Stream url="wss://your-server.com/media?flow=reservation"/>
  </Connect>
</Response>"""
    elif digit == "2":
        xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">결제 문의 안내입니다. 주문번호를 말씀해주세요.</Say>
  <Connect>
    <Stream url="wss://your-server.com/media?flow=payment"/>
  </Connect>
</Response>"""
    else:  # "0" — 상담사 연결
        xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">상담사에게 연결해드리겠습니다.</Say>
  <Dial timeout="20" action="https://your-server.com/dial-status">
    <Number>07012340001</Number>
  </Dial>
</Response>"""
    return Response(xml, mimetype="application/xml")

D. 발신자 history 기반 (DB 조회)

발신자 history 로 첫 안내 멘트(<Say>)를 바꾸고 AI 로 연결한다. (AI 응대 컨텍스트는 <Stream> URL 쿼리로 WebSocket 핸들러에 전달.)

from xml.sax.saxutils import escape

@app.route("/voice-route", methods=["POST"])
def voice_route():
    caller = request.form["From"]
    customer = db.query("SELECT * FROM customers WHERE phone = %s", caller).fetchone()

    if customer is None:
        greeting = "안녕하세요, 처음 전화 주신 분이시군요. ABC 회사입니다."
        ctx = "new"
    elif customer.tier == "premium":
        greeting = f"{customer.name}님 안녕하세요. 프리미엄 고객 전용 응대입니다."
        ctx = "premium"
    elif customer.unpaid_invoices > 0:
        greeting = f"{customer.name}님, 미결제 청구건이 있어서 안내드립니다."
        ctx = "unpaid"
    else:
        greeting = f"{customer.name}님 안녕하세요. 어떤 도움이 필요하신가요?"
        ctx = "returning"

    xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">{escape(greeting)}</Say>
  <Connect>
    <Stream url="wss://your-server.com/media?ctx={ctx}"/>
  </Connect>
</Response>"""
    return Response(xml, mimetype="application/xml")

E. 부재중 fallback (영업시간 외 / AI 처리 실패)

영업시간 외에는 안내 후 <Record> 로 음성 메모를 받는다. 녹음이 끝나면 action URL 로 RecordingUrl/RecordingDuration 이 POST 되니, 거기서 요약·callback 큐 적재를 한다.

@app.route("/voice-route", methods=["POST"])
def voice_route():
    # 영업시간 외 → 메시지 받기 모드
    xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">지금은 영업시간이 아닙니다. 성함, 연락처, 용건을 차례로 말씀해주시면 다음 영업일에 콜백 드립니다.</Say>
  <Record maxLength="60" playBeep="true" action="https://your-server.com/voicemail"/>
</Response>"""
    return Response(xml, mimetype="application/xml")

@app.route("/voicemail", methods=["POST"])      # Record action — 녹음 완료 후 호출
def voicemail():
    recording_url = request.form.get("RecordingUrl")
    duration = request.form.get("RecordingDuration")
    # 여기서 STT 요약 → callback 큐 적재 등 후처리
    save_voicemail(recording_url, duration)
    xml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">메모 잘 받았습니다. 다음 영업일에 연락드리겠습니다. 감사합니다.</Say>
  <Hangup/>
</Response>"""
    return Response(xml, mimetype="application/xml")

4. 작동 원리 — 분기 흐름

[고객] → 070 다이얼
   ↓
[ClawOps SIP 수신]
   ↓ POST /voice-route
[당신의 endpoint] 라우팅 결정
   ↓ VoiceML(XML) 응답
[AI 응대 <Connect><Stream>] OR [<Dial> 전환] OR [<Record> 메모]
   ↓
[고객]

5. 성능 — endpoint 응답 시간

ClawOps 는 webhook 응답을 통화 시작 전 200ms 안에 기대. endpoint 가 늦으면 통화가 지연되니 빠르게 VoiceML 을 반환해야 한다.

  • DB 조회 1개 (<50ms) OK
  • 외부 API 호출은 비동기 큐로

6. 보안

  • 서명키를 등록하면 ClawOps webhook 요청에 X-Signature 헤더로 HMAC-SHA256 서명이 포함된다.
  • 검증:
import hmac, hashlib
def verify(body: bytes, sig: str) -> bool:
    expected = hmac.new(WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

다음 단계

관련 글 더 보기

ClawOps AI 전화 API로 시작하기

070 번호 발급부터 AI 음성 통화까지, REST API 몇 줄이면 됩니다.