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

# 워크스페이스 단위 웹훅

> 워크스페이스 기본 웹훅을 설정하고 HMAC 서명을 검증하는 방법을 안내합니다.

## 개요

워크스페이스 단위 웹훅은 워크스페이스 전체에 적용되는 **기본 웹훅**입니다.
에이전트에 개별 웹훅이 없으면 워크스페이스 웹훅으로 자동 fallback됩니다.

<Note>
  이 페이지는 웹훅 설정과 **HMAC 서명 검증**을 다룹니다.
  이벤트 타입과 페이로드 예시는 [에이전트 단위 웹훅](/docs/operate/monitor/webhooks/agent) 문서를 참고하세요.
  필드 타입은 [웹훅 스키마](/docs/operate/monitor/webhooks/schema)에서 확인할 수 있습니다.
</Note>

<Info>
  워크스페이스 웹훅은 오너와 관리자가 변경할 수 있습니다.
  저장된 웹훅 테스트 전송은 멤버도 실행할 수 있습니다.
  역할별 권한은 [워크스페이스 역할](/docs/workspace/roles)을 참고하세요.
</Info>

## 웹훅 URL 설정

통화 데이터 웹훅 URL은 두 곳에서 설정할 수 있습니다.

| 레벨         | 설정 위치              | 용도               |
| ---------- | ------------------ | ---------------- |
| **워크스페이스** | 설정 > 웹훅            | 워크스페이스 기본 웹훅 URL |
| **에이전트**   | 에이전트 설정 > 웹훅 설정 섹션 | 에이전트별 개별 웹훅 URL  |

<Info>
  에이전트 웹훅 URL이 설정된 경우 해당 URL이 우선 적용됩니다.
  미설정 시 워크스페이스 웹훅 URL로 자동 fallback됩니다.
</Info>

<Info>
  Transfer Agent로 전환되더라도 최초 인입 Agent의 웹훅 URL이 사용됩니다.
</Info>

### 웹훅 버전

새 웹훅 연동은 v2 페이로드를 기준으로 설정됩니다.
대시보드에 v1 선택지가 보이는 경우는 기존 v1 수신 서버를 유지하거나 v2로
전환하기 위한 마이그레이션용입니다.

에이전트 웹훅 URL이 있으면 에이전트 설정이 우선 적용되고, 에이전트 URL이 없으면
워크스페이스 설정이 사용됩니다.

| 구분       | 동작                                       |
| -------- | ---------------------------------------- |
| 새 연동     | v2 페이로드 기준으로 구현하세요                       |
| 기존 v1 연동 | 기존 수신 서버를 유지하거나 v2로 전환할 때만 v1 선택지를 사용하세요 |

<Note>
  v2는 `call_ended`와 `call_analyzed`를 분리합니다.
  자세한 차이는 [에이전트 단위 웹훅](/docs/operate/monitor/webhooks/agent#지원되는-이벤트)을 참고하세요.
</Note>

## 설정

<Steps>
  <Step title="웹훅 URL 등록">
    대시보드 **설정 > 웹훅**에서 워크스페이스 웹훅 URL을 입력하세요.
    빈 값으로 저장하면 웹훅 전송이 중지됩니다.
  </Step>

  <Step title="서명 키 생성">
    같은 화면에서 **웹훅 서명 키**를 생성하세요.
    생성된 키는 **한 번만 표시**되므로 안전한 곳에 보관하세요.
  </Step>
</Steps>

## 보안

vox.ai는 **IP 화이트리스트**와 **HMAC-SHA256 서명** 두 가지 보안 레이어를 제공합니다.

```mermaid theme={null}
sequenceDiagram
    participant V as vox.ai Server
    participant C as 고객 서버

    V->>V: HMAC-SHA256 서명 생성
    V->>C: POST /webhook<br/>+ X-Webhook-Timestamp<br/>+ X-Webhook-Signature
    C->>C: 1. IP 허용 목록 확인
    C->>C: 2. Timestamp 유효성 확인 (5분 이내)
    C->>C: 3. HMAC 서명 검증
    C->>C: 4. 페이로드 처리
    C-->>V: 200 OK
```

### IP 화이트리스트

vox.ai 프로덕션 웹훅 요청은 API 서버의 고정 외부 IP `34.64.213.13`에서 전송됩니다.
방화벽 또는 리버스 프록시에서 이 IP만 허용하세요.

### HMAC-SHA256 서명

웹훅 요청에는 아래 헤더가 포함됩니다.

| 헤더                    | 설명                 | 예시                 |
| --------------------- | ------------------ | ------------------ |
| `X-Webhook-Timestamp` | 서명 생성 시점 (Unix, 초) | `1738900000`       |
| `X-Webhook-Signature` | HMAC-SHA256 서명     | `sha256=a1b2c3...` |

서명은 다음 규칙으로 계산하세요.

```
signed_payload = "{timestamp}.{raw_request_body}"
signature = HMAC_SHA256(secret, signed_payload)
```

`raw_request_body`는 HTTP 요청에서 받은 원본 본문입니다.
JSON을 파싱하거나 다시 직렬화하지 마세요.
공백, 키 순서, 이스케이프 방식이 바뀌면 서명이 달라집니다.

<Tip>
  서명 검증이 끝난 뒤에 JSON을 파싱하세요.
  프레임워크의 JSON 파서가 본문을 소비하기 전에 원본 본문을 확보해야 합니다.
</Tip>

<Note>
  에이전트의 [인바운드 웹훅](/docs/build/variables/inbound-webhook#보안)에도 같은 서명 키와 같은 검증 방식을 적용할 수 있습니다.
  에이전트 설정의 **인바운드 웹훅 서명** 토글로 켭니다 (기본 꺼짐).
</Note>

### 웹훅 서명 키 관리

대시보드 **설정 > 웹훅**에서 서명 키를 관리하세요.

<Steps>
  <Step title="키 생성">
    **설정 > 웹훅** 페이지에서 **키 발급** 버튼을 클릭하세요.
  </Step>

  <Step title="키 복사 및 저장">
    생성된 키를 복사하여 서버 환경 변수에 저장하세요.

    ```bash theme={null}
    # .env
    VOX_WEBHOOK_KEY=a3f7c9d2e5b8a1f4c6d9e2b5a8f1c4d7e0b3a6f9c2d5e8b1a4f7c0d3e6b9a2f5
    ```

    <Warning>
      웹훅 서명 키는 **생성 직후 한 번만** 전체 값이 표시됩니다.
      이후에는 마지막 4자리만 확인할 수 있으므로, 반드시 안전한 곳에 저장하세요.
    </Warning>
  </Step>
</Steps>

<Info>
  **서명 키 재발급** — 기존 키 삭제 후 새 키를 생성합니다. 기존 키로 서명된 웹훅은 즉시 검증 실패합니다.
  **서명 키 삭제** — HMAC 서명이 비활성화되고, 웹훅 요청에 서명 헤더가 포함되지 않습니다.
</Info>

## 서버 구현

HMAC 서명 검증이 포함된 엔드포인트 예제를 참고하세요.

<CodeGroup>
  ```python Python (FastAPI) theme={null}
  import hmac, hashlib, json, time, os
  from fastapi import FastAPI, Request, HTTPException

  app = FastAPI()
  WEBHOOK_KEY = os.environ["VOX_WEBHOOK_KEY"]
  TOLERANCE = 5 * 60  # 5분

  def verify_webhook(body: bytes, timestamp: str, signature: str, secret: str) -> bool:
      if not timestamp or not signature:
          return False

      try:
          timestamp_seconds = int(timestamp)
      except ValueError:
          return False

      if abs(time.time() - timestamp_seconds) > TOLERANCE:
          return False

      provided = signature.removeprefix("sha256=")
      expected = hmac.new(
          secret.encode("utf-8"),
          timestamp.encode("utf-8") + b"." + body,
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(expected, provided)

  @app.post("/webhook")
  async def handle_webhook(request: Request):
      body = await request.body()
      ts = request.headers.get("X-Webhook-Timestamp", "")
      sig = request.headers.get("X-Webhook-Signature", "")

      if not verify_webhook(body, ts, sig, WEBHOOK_KEY):
          raise HTTPException(status_code=401)

      data = json.loads(body)
      # 페이로드 처리 ...
  ```

  ```javascript JavaScript (Express) theme={null}
  const crypto = require("crypto");
  const express = require("express");

  const app = express();
  const WEBHOOK_KEY = process.env.VOX_WEBHOOK_KEY;
  const TOLERANCE = 5 * 60; // 5분

  function verifyWebhook(body, timestamp, signature, secret) {
    if (!timestamp || !signature) return false;
    if (!Buffer.isBuffer(body)) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE) return false;

    const provided = signature.replace("sha256=", "");
    if (!/^[0-9a-f]{64}$/i.test(provided)) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), body]))
      .digest("hex");

    return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(provided, "hex"));
  }

  app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
    const ts = String(req.headers["x-webhook-timestamp"] || "");
    const sig = String(req.headers["x-webhook-signature"] || "");

    if (!verifyWebhook(req.body, ts, sig, WEBHOOK_KEY)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const data = JSON.parse(req.body.toString("utf8"));
    // 페이로드 처리 ...
  });
  ```
</CodeGroup>

<Tip>
  서명 검증 후 **HTTP 200**을 빠르게 반환하세요.
  후속 처리(저장/분석 등)는 비동기로 분리하세요.
</Tip>

## 보안 권장사항

<CardGroup cols={2}>
  <Card title="두 레이어 모두 사용" icon="shield-halved">
    IP 화이트리스트와 HMAC 서명을 함께 사용하세요. 네트워크와 애플리케이션 레벨 모두 보안을 확보할 수 있습니다.
  </Card>

  <Card title="Timestamp 검증" icon="clock">
    타임스탬프가 현재 시간과 5분 이내인지 확인하여 Replay 공격을 방지하세요.
  </Card>

  <Card title="Timing-safe 비교" icon="lock">
    `hmac.compare_digest` (Python) 또는 `crypto.timingSafeEqual` (Node.js)로 Timing 공격을 방지하세요.
  </Card>

  <Card title="키 안전 보관" icon="key">
    웹훅 서명 키는 환경 변수 또는 시크릿 매니저에 저장하고, 코드에 하드코딩하지 마세요.
  </Card>
</CardGroup>

## 관련 문서

* [웹훅 개요](/docs/operate/monitor/webhooks/overview) — 웹훅 유형과 우선순위
* [에이전트 단위 웹훅](/docs/operate/monitor/webhooks/agent) — 이벤트 타입과 페이로드 예시
* [웹훅 스키마](/docs/operate/monitor/webhooks/schema) — 이벤트별 필드 레퍼런스
* [빌드: 웹훅 설정](/docs/build/security/webhooks) — 에이전트 빌드 시 웹훅 설정

***

<Accordion title="연관 검색어">
  워크스페이스 웹훅, organization webhook, HMAC, 서명 검증, 웹훅 보안, IP 화이트리스트, 서명 키, webhook v2, webhook v1
</Accordion>
