> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thunderphone.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 웹훅 서명 확인

> ThunderPhone에서 전송되는 모든 웹훅 및 도구 요청에는 서명이 포함됩니다. 한 번 확인하고 어디서나 재사용하세요.

서버로 전송하는 모든 요청(웹훅 전송 및 도구 엔드포인트 호출)에는
`X-ThunderPhone-Signature` 헤더에 HMAC-SHA256 서명이 포함됩니다. 검증을 한 번만 올바르게 구현하고
모든 핸들러에서 동일한 헬퍼를 사용합니다.

## 알고리즘

1. **원본** 요청 본문, 즉 ThunderPhone이 POST한 정확한 바이트를 읽습니다.
2. `hmac_sha256(secret, body).hexdigest()`를 계산합니다.
3. `X-ThunderPhone-Signature`와 **상수 시간**으로 비교합니다.
   (일반적인 문자열 비교는 타이밍 정보를 노출합니다.)

ThunderPhone은 전송하는 정확한 바이트에 서명하므로 원본 본문을
검증하면 항상 작동합니다. 이 바이트는 페이로드의 **정규 JSON 직렬화**이기도 합니다.
키는 알파벳순으로 정렬되고, 구분 기호는 공백 없는
(`,` 및 `:`) 형식이며, UTF-8을 사용합니다. 프레임워크가 파싱된 JSON만 제공하는 경우에도
완전히 동등한 두 번째 방법을 사용할 수 있습니다.
정규 형식으로 다시 직렬화한 후 해당 값으로 HMAC을 계산합니다.

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

원본 본문을 사용하는 것이 좋습니다. 단계가 하나 줄어들고 일부 언어에서 발생하는
JSON 숫자 왕복 변환 문제의 영향을 받지 않습니다.

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

| 소스                                                                      | 시크릿                                                         |
| ----------------------------------------------------------------------- | ----------------------------------------------------------- |
| [웹훅 엔드포인트](/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 웹훅과 동일한 시크릿) — 엔드포인트별 시크릿이 아닙니다 |

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

## 참조 구현

다음 네 가지 구현은 모두 원본 요청 본문을 검증합니다.

<CodeGroup>
  ```python Python theme={null}
  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 theme={null}
  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 theme={null}
  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 theme={null}
  require "openssl"

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

## 프레임워크별 연결

<CodeGroup>
  ```python FastAPI theme={null}
  from fastapi import FastAPI, HTTPException, Request

  app = FastAPI()

  @app.post("/thunderphone-webhook")
  async def hook(request: Request):
      body = await request.body()           # raw bytes, NOT request.json()
      sig = request.headers.get("X-ThunderPhone-Signature", "")
      if not verify(body, sig, SECRET):
          raise HTTPException(status_code=401)

      import json
      event = json.loads(body)
      # … dispatch on event["type"] …
      return {"ok": True}
  ```

  ```javascript Express theme={null}
  import express from "express";

  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 sig = req.header("X-ThunderPhone-Signature") || "";
      if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
        return res.sendStatus(401);
      }
      const event = JSON.parse(req.body.toString("utf8"));
      // … dispatch on event.type …
      res.sendStatus(204);
    },
  );
  ```

  ```python Django theme={null}
  import json

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


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

## 도구 호출 검증

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

* `X-ThunderPhone-Call-ID` — 진행 중인 통화의 숫자 ID입니다.
* `X-ThunderPhone-Signature` — 정확한 요청 본문 바이트에 대해
  **조직 수준 웹훅 시크릿**을 키로 사용하는 HMAC-SHA256입니다.

동일한 `verify()` 헬퍼를 수정 없이 사용할 수 있지만, 다음 두 가지 차이점이 있습니다.

1. **`GET` / `DELETE` 도구에는 본문이 없습니다.** 인수는 쿼리
   매개변수로 전달되며, 서명은 **빈 바이트 문자열**에 대해 계산됩니다.
   따라서 Python에서는 `verify(b"", sig, secret)`, Node에서는
   `verify(Buffer.alloc(0), sig, secret)`를 사용합니다. 쿼리 문자열을
   해싱하지 마십시오.
2. **레거시 웹훅이 구성되지 않은 조직에는 조직 시크릿이 없습니다.**
   이 경우 도구 호출에는 `X-ThunderPhone-Call-ID`만 포함되고 서명
   헤더는 포함되지 않습니다. 서명 시크릿을 받으려면 레거시 웹훅
   (`PUT /v1/webhook`)을 구성하거나, `endpoint.headers`를 통해 자체
   헤더로 도구 호출을 인증하십시오.

```python theme={null}
@app.post("/tools/search-appointments")
async def tool(request: Request):
    body = await request.body()  # b"" for GET/DELETE tools
    sig = request.headers.get("X-ThunderPhone-Signature", "")
    call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
    if not verify(body, sig, ORG_WEBHOOK_SECRET):
        raise HTTPException(status_code=401)
    args = json.loads(body)
    ...
```

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

## 일반적인 주의 사항

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

  <Accordion title="GET/DELETE 도구에서 쿼리 문자열 해싱">
    본문이 없는 도구 메서드에서는 서명이 빈 바이트 문자열을 대상으로 하므로
    하나의 범용 방식이 유지됩니다. 즉, 어떤 내용이든 원시 요청 본문에 HMAC을 적용합니다.
    URL이나 쿼리 문자열을 해싱하면 절대 일치하지 않습니다.
  </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="함수 도구" icon="screwdriver-wrench" href="/ko/tools/overview">
    두 가지 도구 호출 경로와 요청 형식입니다.
  </Card>

  <Card title="도구 통합" icon="wrench" href="/ko/guides/build-tool-integration">
    도구 기반 통합을 처음부터 끝까지 구축합니다.
  </Card>
</CardGroup>
