새로운 문서를 작성 중입니다.새 문서로 이동
ClawOps Docs

서명 검증

모든 Webhook 요청에 포함되는 X-Signature 헤더를 사용한 HMAC-SHA256 서명 검증 방법을 안내합니다.

ClawOps는 모든 웹훅 요청에 X-Signature 헤더를 포함합니다. HMAC-SHA256 기반으로 요청의 무결성을 검증할 수 있습니다.

프로덕션 환경에서는 반드시 서명을 검증하세요. 서명 검증을 하지 않으면 외부에서 위조된 요청을 보낼 수 있습니다.

이 서명 검증 방법은 수신 전화(VoiceML), Status Callback, Message Webhook 모두에 동일하게 적용됩니다.

서명 생성 알고리즘

  1. 웹훅 URL 문자열로 시작
  2. 파라미터를 키 사전순(알파벳)으로 정렬
  3. 각 key + value를 URL 뒤에 순서대로 연결
  4. 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 기준의 상태 관리로 재전송을 걸러내세요.