---
title: "웹훅 서명 확인"
description: "ThunderPhone이 전송하는 모든 웹훅 및 도구 요청에는 타임스탬프가 포함된 서명이 적용됩니다. 여기의 레시피 또는 SDK 헬퍼로 이를 확인하고, 5분보다 오래된 재전송 요청은 거부합니다."
---

서버로 전송하는 모든 요청(웹훅 전송, 레거시 단일 URL 웹훅 및 도구 엔드포인트 호출)에는 두 개의 서명 헤더가 포함됩니다. 둘 다 동일한 시크릿을 사용하는 HMAC-SHA256입니다.

| 헤더 | 서명 대상 | 재전송 방지 |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (권장) | 타임스탬프, 메서드, 경로, 쿼리 및 본문 | 5분보다 오래된 요청 거부 |
| `X-ThunderPhone-Signature` | 본문만 | 없음: 캡처된 요청은 영구적으로 검증됨 |

새 통합에서는 `X-ThunderPhone-Signature-V2`를 검증하고, 기존 통합도 이를 사용하도록 전환합니다. `X-ThunderPhone-Signature`는 변경 없이 모든 요청에 계속 전송되므로, 이를 기반으로 구축된 검증기는 계속 작동합니다. 전송을 중단하기 전에 날짜를 공지합니다.

## 서명 V2

```http
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
```

- `t`는 요청을 전송한 시점의 Unix 시간(초)입니다. 재시도된
  전송은 전송 시점에 다시 서명되며, 자체 `t` 값을 갖습니다.
- `v1`은 아래 서명 문자열의 16진수 HMAC-SHA256이며, 고객님의
  시크릿을 키로 사용합니다.

값은 쉼표로 구분된 `key=value` 쌍 목록입니다. 알 수 없는 키는
무시하고, `v1` 중 하나라도 일치하면 요청을 수락합니다. 향후 변경 시
두 번째 서명을 추가할 수 있습니다.

### 서명 문자열

마지막 필드를 제외한 각 필드 뒤에는 줄바꿈 하나(`\n`, 바이트 `0x0A`)가
있습니다.

```text
<t>\n<METHOD>\n<path and query>\n<body>
```

| 필드 | 값 |
|-------|-------|
| `t` | 헤더의 `t` 값 |
| `METHOD` | 대문자 HTTP 메서드: `POST`, `GET`, `PUT`, `PATCH` 또는 `DELETE` |
| path and query | HTTP 요청 줄의 요청 대상입니다. 경로 뒤에 쿼리 문자열이 있는 경우 `?`와 쿼리 문자열이 이어집니다. 스킴, 호스트, 프래그먼트는 포함하지 않습니다. 디코딩하거나 매개변수 순서를 변경하지 말고, 수신한 그대로 사용합니다 |
| `body` | 원시 요청 본문입니다. `GET` 및 `DELETE` 도구 호출에는 본문이 없으므로, 서명 문자열은 경로 뒤의 줄바꿈으로 끝납니다 |

검증하려면 다음을 수행합니다.

1. 파싱하기 전에 헤더, 메서드, 원시 경로 및 쿼리, 원시 본문을 읽습니다.
2. 헤더에서 `t`와 모든 `v1`을 가져옵니다.
3. `t`가 현재 시간보다 이전 또는 이후로 **300초**를 초과하면 요청을 거부합니다.
4. 시크릿을 사용하여 서명 문자열의 16진수 HMAC-SHA256을 계산합니다.
5. `v1`과 상수 시간으로 비교합니다. 일치하는 값이 없으면 `401`로 응답합니다.

### 동작 예시

시크릿은 `whsec_example_4c1f2a9b`입니다. 다음 요청을
`t = 1767225600`(2026-01-01 00:00:00 UTC)에 전송합니다.

```http
POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e

{"customer_id":"cust_123"}
```

서명 문자열은 다음 네 줄이며, 본문 뒤에는 줄바꿈이 없습니다.

```text
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}
```

HMAC-SHA256은
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`입니다.
이를 사용해 구현을 확인하거나 `openssl`로 재현할 수 있습니다.

```bash
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'
```

같은 초에 `/tools/lookup?customer_id=cust_123`으로 보내는 `GET` 도구
호출은 빈 본문을 갖습니다. 서명 문자열은
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n`이며 서명은
`3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`입니다.

### SDK 사용

[Python 및 TypeScript SDK](/api-reference/sdks)에는 도우미가 포함되어
있습니다(`thunderphone` 0.2.0 이상 또는 `@thunderphone/sdk` 0.2.0 이상).
이 도우미는 5분 허용 범위를 확인하고, 상수 시간으로 비교하며, 다운타임
없이 교체할 수 있도록 시크릿 목록을 허용합니다.

<CodeGroup>
```python Python
from thunderphone import verify_signature

ok = verify_signature(
    header,            # the X-ThunderPhone-Signature-V2 value
    secret=SECRET,     # or [NEW_SECRET, OLD_SECRET] while rotating
    method=method,     # "POST"
    path=path,         # "/tools/lookup?region=us", exactly as received
    body=raw_body,     # bytes, before any JSON parsing
    tolerance=300,     # optional; seconds
)
```

```javascript Node.js
import { verifySignature } from "@thunderphone/sdk";

const ok = verifySignature({
  header,               // the X-ThunderPhone-Signature-V2 value
  secret: SECRET,       // or [NEW_SECRET, OLD_SECRET] while rotating
  method,               // "POST"
  path,                 // "/tools/lookup?region=us", exactly as received
  body: rawBody,        // Buffer or string, before any JSON parsing
  toleranceSeconds: 300, // optional
});
```
</CodeGroup>

### SDK 없이

<CodeGroup>
```python Python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
    """path is the path plus ?query exactly as received; body is the raw body."""
    timestamp, signatures = None, []
    for item in (header or "").split(","):
        key, _, value = item.strip().partition("=")
        if key == "t" and value.isdigit() and len(value) <= 15:
            timestamp = int(value)
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)
```

```javascript Node.js
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
  let timestamp;
  const signatures = [];
  for (const item of (header || "").split(",")) {
    const [key, value = ""] = item.trim().split("=", 2);
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1") signatures.push(value);
  }
  if (timestamp === undefined) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = Buffer.from(
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
      .update(body)
      .digest("hex"),
  );
  return signatures.some((sig) => {
    const candidate = Buffer.from(sig);
    return (
      candidate.length === expected.length &&
      crypto.timingSafeEqual(candidate, expected)
    );
  });
}
```
</CodeGroup>

### 프레임워크 연결

각 예시는 원시 경로와 쿼리, 원시 본문을 읽은 다음 SDK 헬퍼를 호출합니다.

<CodeGroup>
```python FastAPI
import json

from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature

app = FastAPI()


def raw_path_and_query(request: Request) -> str:
    path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
    query = request.scope.get("query_string", b"").decode("latin-1")
    return f"{path}?{query}" if query else path


@app.post("/thunderphone-webhook")
async def hook(request: Request):
    body = await request.body()  # raw bytes, NOT request.json()
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    event = json.loads(body)
    # … dispatch on event["type"] …
    return {"ok": True}
```

```javascript Express
import express from "express";
import { verifySignature } from "@thunderphone/sdk";

const app = express();

app.post(
  "/thunderphone-webhook",
  // IMPORTANT: parse as raw; do NOT use express.json() here.
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifySignature({
      header: req.header("X-ThunderPhone-Signature-V2"),
      secret: process.env.WEBHOOK_SECRET,
      method: req.method,
      path: req.originalUrl, // path and query as received
      body: req.body,
    });
    if (!ok) return res.sendStatus(401);
    const event = JSON.parse(req.body.toString("utf8"));
    // … dispatch on event.type …
    res.sendStatus(204);
  },
);
```

```python Django
import json

from django.http import HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature


@csrf_exempt
@require_POST
def hook(request):
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=request.get_full_path(),
        body=request.body,  # raw bytes
    ):
        return HttpResponseForbidden("invalid signature")
    event = json.loads(request.body)
    # … dispatch on event["type"] …
    return JsonResponse({"ok": True})
```
</CodeGroup>

`req.originalUrl`(Express)과 ASGI의 `raw_path`, `query_string`
(FastAPI)은 수신된 그대로의 요청 대상입니다. Django의
`get_full_path()`와 Flask의 `request.path`는 먼저 경로를 디코딩하므로,
일반 경로에서는 동일한 문자열을 반환합니다. 엔드포인트 경로에
`%2F`, `;`, `=` 같은 퍼센트 인코딩 문자나 예약 문자가 포함되어 있으면,
대신 서버가 제공하는 원시 요청 대상을 읽으십시오(예: Gunicorn의
`request.META["RAW_URI"]`).

## 어떤 시크릿을 사용하나요?

동일한 시크릿이 두 헤더에 모두 서명합니다.

| 소스 | 시크릿 |
|--------|--------|
| [웹훅 엔드포인트](/ko/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | 생성 시 한 번 반환되는 엔드포인트별 `secret`(16진수 문자 48개) |
| [레거시 단일 URL 웹훅](/api-reference/organizations#legacy-single-url-webhook) | `GET /v1/webhook`에서 반환되는 조직별 `secret` |
| [도구 엔드포인트 호출](/ko/tools/overview) (사용자의 `endpoint.url`로 직접 호출) | **조직 수준 웹훅 시크릿**(레거시 단일 URL 웹훅과 동일한 시크릿)이며, 엔드포인트별 시크릿이 아닙니다. |

시크릿은 시크릿 관리자 또는 환경 변수에 저장하고, 절대 커밋하지 마세요.

## 도구 호출 검증

에이전트가
[함수 도구](/ko/tools/overview) 중 하나를 직접 호출하는 경우(도구에
`endpoint`가 있는 경우), 요청에는 구성한 `endpoint.headers`와 함께 다음 ThunderPhone 헤더가 포함됩니다.

- `X-ThunderPhone-Call-ID` — 진행 중인 통화의 숫자 ID입니다.
- `X-ThunderPhone-Signature-V2` — **조직 수준 웹훅 시크릿**으로 키 지정된 위의 타임스탬프 포함 서명입니다.
- `X-ThunderPhone-Signature` — 동일한 키를 사용하는 레거시 본문 전용 서명입니다.

웹훅과 다른 점은 두 가지입니다.

1. **`GET` / `DELETE` 도구에는 본문이 없습니다.** 인수는 쿼리 매개변수로 전달됩니다. V2 서명은 경로와 쿼리를 통해 이를 포함하며, 본문 필드는 비어 있습니다. 원시 쿼리 문자열로 검증합니다. 매개변수를 파싱하고 새 쿼리 문자열을 만들면 순서나 인코딩이 변경될 수 있습니다. 레거시 헤더는 빈 바이트 문자열에 서명합니다.
2. **레거시 웹훅이 구성되지 않은 조직에는 조직 시크릿이 없습니다.** 이 경우 도구 호출에는 `X-ThunderPhone-Call-ID`만 포함되며 서명 헤더는 없습니다. 서명 시크릿을 얻으려면 레거시 웹훅(`PUT /v1/webhook`)을 구성하거나, `endpoint.headers`를 통해 자체 헤더로 도구 호출을 인증합니다.

```python
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
    body = await request.body()  # b"" for GET/DELETE tools
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=ORG_WEBHOOK_SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
    args = json.loads(body) if body else dict(request.query_params)
    ...
```

Express에서는 본문 파서가 `GET` 요청의 `req.body`를 빈 객체로 남겨 두므로, `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`를 전달합니다.

웹훅 **모드** 도구 디스패치(`endpoint`가 없는 도구이며,
`telephony.tool` / `web.tool`로 조직 웹훅에 전달됨)는 일반적인
서명 웹훅입니다. 위의 표준 방식을 적용하세요. 두 요청 형식은
[함수 도구](/ko/tools/overview)를 참조하세요.

## 레거시 서명: `X-ThunderPhone-Signature`

원래 헤더는 동일한 시크릿을 사용하여 원본 본문만 대상으로 계산한 16진수 HMAC-SHA256입니다. 타임스탬프가 없으므로 서명된 요청 하나를 캡처한 사람은 누구나 이를 재전송할 수 있습니다. 통합이 V2로 전환되는 동안에도 모든 요청에 계속 전송됩니다.

1. **원본** 요청 본문, 즉 ThunderPhone이 전송한 정확한 바이트를 읽습니다.
2. `hmac_sha256(secret, body).hexdigest()`를 계산합니다.
3. `X-ThunderPhone-Signature`와 **상수 시간**으로 비교합니다.

ThunderPhone은 전송하는 정확한 바이트에 서명합니다. 이 바이트는 페이로드의 **정규 JSON 직렬화**이기도 합니다. 즉, 키는 알파벳순으로 정렬되고, 구분자는 공백 없는 `,` 및 `:`이며, 인코딩은 UTF-8입니다. 프레임워크가 파싱된 JSON만 제공하는 경우, 이를 정규 방식으로 다시 직렬화한 후 HMAC을 계산합니다.

```python
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
```

원본 본문을 사용하는 것이 좋습니다. 단계가 하나 줄어들고 일부 언어에서 발생하는 JSON 숫자 왕복 변환의 특이점에도 영향을 받지 않습니다. V2의 본문 필드에도 동일하게 적용됩니다.

<CodeGroup>
```python Python
import hashlib
import hmac


def verify(body: bytes, signature: str, secret: str) -> bool:
    """Constant-time HMAC-SHA256 verification."""
    expected = hmac.new(
        secret.encode("utf-8"),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

```javascript Node.js
import crypto from "node:crypto";

export function verify(body, signature, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  if (!signature || expected.length !== signature.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature),
  );
}
```

```go Go
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func Verify(body []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}
```

```ruby Ruby
require "openssl"

def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end
```
</CodeGroup>

## 일반적인 문제

<AccordionGroup>
  <Accordion title="시계 오차">
    5분 윈도는 서버의 시계를 기준으로 측정됩니다. NTP와 동기화 상태를
    유지하십시오. 5분 이상 차이 나는 시계는 모든 요청을
    거부합니다.
  </Accordion>

  <Accordion title="프록시가 경로를 다시 작성하는 경우">
    서명된 경로는 ThunderPhone에서 요청한 경로, 즉 구성한 URL의
    경로입니다. 애플리케이션이 요청을 확인하기 전에 리버스 프록시 또는 API 게이트웨이가
    접두사를 제거하거나 추가하는 경우, 검증하기 전에 전송된 경로를
    다시 구성하십시오(예: 접두사를 다시 추가).
  </Accordion>

  <Accordion title="기본 형식으로 다시 직렬화하는 경우">
    본문을 파싱한 후 JSON 라이브러리의 기본 설정( `,` / `:` 뒤 공백,
    삽입 순서 키)으로 다시 덤프하면 서로 다른 바이트가 생성되어
    HMAC이 손상됩니다. 원시 본문을 검증하십시오. 다시 직렬화해야 하는 경우에는
    정렬된 키, 압축 구분자, UTF-8이라는 표준 형식과 정확히 일치해야
    합니다.
  </Accordion>

  <Accordion title="프레임워크가 JSON을 자동 파싱">
    Express의 `express.json()` 미들웨어는 본문 스트림을 소비하므로 원본 바이트를 잃게 됩니다.
    웹훅 라우트에는 `express.raw()`를 사용하거나 사전 미들웨어에서 원본 본문을 버퍼링하세요.
    NestJS / Koa도 마찬가지이므로 "raw body" 문서를 확인하세요.
  </Accordion>

  <Accordion title="타이밍 공격에 안전하지 않은 비교">
    JS의 `expected === signature` 또는 Python의 `expected == signature`는
    실행 시간이 달라지는 비교입니다. 각각 `crypto.timingSafeEqual` 또는
    `hmac.compare_digest`를 사용하세요. 성능 차이는 없습니다.
  </Accordion>

  <Accordion title="도구 엔드포인트에 잘못된 시크릿을 사용하는 경우">
    직접 도구 엔드포인트 호출은 **조직 수준 웹훅
    시크릿** (`GET /v1/webhook`)으로 서명됩니다. `/v1/developer/webhook-endpoints`의 엔드포인트별 시크릿으로
    서명되는 것이 아닙니다. 동일한 헬퍼를 재사용하되,
    도구 경로에서는 조직 시크릿을 전달해야 합니다.
  </Accordion>

  <Accordion title="GET/DELETE 도구의 쿼리 문자열">
    V2는 전송된 쿼리 문자열을 정확히 그대로 서명합니다. 레거시 헤더는
    빈 본문에 서명하며 쿼리 문자열에는 절대 서명하지 않으므로,
    레거시 헤더용으로 URL을 해싱해도 일치하지 않습니다.
  </Accordion>

  <Accordion title="윈도 내 중복">
    타임스탬프는 5분보다 오래된 재생 공격을 차단할 뿐, 해당 윈도 내의
    두 번째 사본은 차단하지 않습니다. 엔드포인트 전달은 최소 한 번 수행되므로
    동일한 이벤트의 재시도가 도착할 수도 있습니다. 엔벌로프의
    `event_id`를 기준으로 중복을 제거하십시오.
  </Accordion>

  <Accordion title="불일치 시 401을 반환하지 않음">
    검증 실패 시 200을 반환하면 핸들러가 재전송 공격의 대상이 됩니다.
    검증에 실패하면 항상 2xx가 아닌 응답을 반환하세요.
  </Accordion>
</AccordionGroup>

---

## 다음 단계

<CardGroup cols={2}>
  <Card title="웹훅 개요" icon="bolt" href="/ko/webhooks/overview">
    전송 의미 체계, 재시도, 소스 IP입니다.
  </Card>
  <Card title="웹훅 엔드포인트" icon="plug" href="/ko/webhooks/endpoints">
    여러 URL을 관리하고 시크릿을 교체합니다.
  </Card>
  <Card title="Function Tools" icon="screwdriver-wrench" href="/ko/tools/overview">
    두 가지 도구 호출 경로와 요청 형식입니다.
  </Card>
  <Card title="도구 통합" icon="wrench" href="/ko/guides/build-tool-integration">
    도구 기반 통합을 처음부터 끝까지 구축합니다.
  </Card>
</CardGroup>
