서명 검증
모든 Webhook 요청에 포함되는 X-Signature 헤더를 사용한 HMAC-SHA256 서명 검증 방법을 안내합니다.
ClawOps는 모든 웹훅 요청에 X-Signature 헤더를 포함합니다. HMAC-SHA256 기반으로 요청의 무결성을 검증할 수 있습니다.
프로덕션 환경에서는 반드시 서명을 검증하세요. 서명 검증을 하지 않으면 외부에서 위조된 요청을 보낼 수 있습니다.
이 서명 검증 방법은 수신 전화(VoiceML), Status Callback, Message Webhook 모두에 동일하게 적용됩니다.
서명 생성 알고리즘
- 웹훅 URL 문자열로 시작
- 파라미터를 키 사전순(알파벳)으로 정렬
- 각 key + value를 URL 뒤에 순서대로 연결
HMAC-SHA256(signingKey, data)결과를 Base64 인코딩
Python 수동 구현
import hmac, hashlib, base64
from flask import Flask, request
app = Flask(__name__)
SIGNING_KEY = "your_signing_key"
@app.route("/webhook", methods=["POST"])
def webhook():
signature = request.headers.get("X-Signature", "")
params = request.form.to_dict()
url = request.url
# 파라미터를 키 사전순으로 정렬 후 URL 뒤에 연결
sorted_params = sorted(params.items())
data = url + "".join(f"{k}{v}" for k, v in sorted_params)
# HMAC-SHA256 서명 생성
expected = base64.b64encode(
hmac.new(SIGNING_KEY.encode(), data.encode(), hashlib.sha256).digest()
).decode()
if not hmac.compare_digest(signature, expected):
return "Unauthorized", 401
# 서명 검증 성공
call_id = request.form["CallId"]
...SDK를 사용한 검증
ClawOps Python SDK의 webhooks.verify() 메서드를 사용하면 서명 검증을 간결하게 처리할 수 있습니다.
from clawops import ClawOps, WebhookVerificationError
client = ClawOps()
@app.route("/webhook", methods=["POST"])
def webhook():
try:
client.webhooks.verify(
url="https://my-app.com/webhook",
params=request.form.to_dict(),
signature=request.headers["X-Signature"],
signing_key="your_signing_key",
)
except WebhookVerificationError:
return "Unauthorized", 401
call_id = request.form["CallId"]
...Signing Key는 대시보드 > 개발자 > API & Webhooks 의 Webhook Signing Secret 에서 확인할 수 있습니다. API Key와는 별도의 값입니다.
재전송(replay) 방어
서명은 요청이 우리에게서 왔다는 것만 보장합니다. 서명까지 그대로 복사한 요청을 나중에 다시 보내는 재전송 공격은 서명 검증만으로는 막을 수 없습니다.
Status Callback · Message Webhook · 계정 레벨 이벤트 웹훅(callflow.ended 등)에는 발생 시각인
Timestamp(ISO 8601)가 들어 있고 이 값도 서명 대상에 포함되므로 위조할 수 없습니다.
서명 검증을 통과한 뒤 시각이 너무 오래됐으면 거부하세요.
from datetime import datetime, timezone
MAX_AGE_SEC = 300 # 5분
ts = datetime.fromisoformat(request.form["Timestamp"].replace("Z", "+00:00"))
age = (datetime.now(timezone.utc) - ts).total_seconds()
if age > MAX_AGE_SEC:
return "Stale webhook", 400재시도로 도착한 요청은 처음 발생 시각을 유지합니다. 허용 범위를 너무 좁게 잡으면 정상 재시도가
거부될 수 있으니 5분 이상을 권장합니다. 중복 수신 자체는 CallId 를 idempotency key 로 처리하세요.
수신 전화(VoiceML) 요청에는 Timestamp 가 없습니다 — 첫 요청과
action 콜백 모두 해당합니다. 위 코드를 그대로 옮기면 값을 찾지 못해 실패하므로, VoiceML
경로에서는 이 방식 대신 action URL 에 직접 넣어 둔 일회성 토큰이나 CallId 기준의 상태
관리로 재전송을 걸러내세요.