070 인바운드 webhook 라우팅 — 시간·번호·DTMF 기준 동적 분기
ClawOps 070 번호로 들어오는 통화를 시간대·발신자 번호·DTMF 응답 기준으로 다른 AI Agent / 상담사 큐 / 부재중 메모로 라우팅하는 패턴.
070 인바운드 webhook 라우팅 — 시간·번호·DTMF 기준 동적 분기
같은 070 번호로 들어오는 통화도 시간대(낮/밤)·발신자(VIP/일반)·DTMF 응답(1=예약/2=문의/3=결제) 기준으로 다른 시나리오로 분기해야 한다. 한국AI통신 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
| 파라미터 | 예시 | 설명 |
|---|---|---|
CallId | CA1a2b... | 통화 고유 ID |
AccountId | AC... | 계정 ID |
From | 01012345678 | 발신 번호 |
To | 07052358010 | 수신 ClawOps 070 |
CallStatus | in-progress | 통화 상태 |
Direction | inbound | 방향 |
당신의 endpoint 는 Content-Type: application/xml 로 VoiceML 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 요청마다 응답을 최대 10초 기다린다. 10초를 넘기거나 2xx 가 아닌 응답(리다이렉트 포함)을 주면 재시도 없이 실패 처리하고, 첫 요청이었다면 발신자에게 오류 안내를 들려준 뒤 끊는다. 첫 요청은 통화를 받기 전에 보내므로 응답이 늦을수록 벨이 길어지고, <Gather action>·<Dial action> 같은 후속 요청이 늦으면 통화 중 침묵이 길어진다. 빠르게 VoiceML 을 반환해야 한다.
- DB 조회 1개 (<50ms) OK
- 외부 API 호출은 비동기 큐로
6. 보안
- 서명키를 등록하면 ClawOps webhook 요청에
X-Signature헤더로 HMAC-SHA256 서명이 포함된다. - 서명 대상은 본문이 아니라 요청 URL 뒤에 파라미터를 키 사전순으로 key+value 이어 붙인 문자열이고, 결과는 Base64 다.
- 검증:
import hmac, hashlib, base64
def verify(url: str, params: dict, sig: str) -> bool:
data = url + "".join(f"{k}{v}" for k, v in sorted(params.items()))
expected = base64.b64encode(
hmac.new(SIGNING_KEY.encode(), data.encode(), hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, sig)
# Flask: verify(request.url, request.form.to_dict(), request.headers.get("X-Signature", ""))
- 자세한 내용은 서명 검증 문서를 본다.