# 웹훅

> 인증 결과 수신, 서명 검증, 중복 이벤트 처리

문서: https://sendwich.kr/docs/webhooks/

## 수신 설정

프로젝트의 **웹훅**에서 수신 URL을 등록하고 사용을 켠 뒤 저장합니다. 수신 주소는 `https://shop.example.com/webhooks/sendwich`와 같이 서비스 서버에 구현합니다.

실제 SMS 프로젝트에는 외부에서 접근 가능한 HTTPS 주소와 443 포트가 필요합니다. localhost, 사설망 주소, 리디렉션은 지원하지 않습니다.

처음 저장할 때 표시되는 `whsec_…` 서명 키를 서버에 보관합니다. 설정은 **저장 후 생성한 인증부터** 적용됩니다.

## 이벤트

| 이벤트 | 상태 |
| --- | --- |
| `verification.verified` | 인증 성공 |
| `verification.expired` | 인증 만료 |
| `verification.cancelled` | 인증 취소 |

각 인증은 하나의 최종 이벤트를 생성합니다. 같은 이벤트가 재전송될 수 있으므로 중복 실행을 방지해야 합니다.

```json
{
  "id": "evt_EXAMPLE",
  "type": "verification.verified",
  "created_at": "2026-09-16T03:01:00.000Z",
  "data": {
    "id": "vrf_EXAMPLE",
    "status": "verified",
    "environment": "live",
    "mode": "discover",
    "phone": "+821012345678",
    "carrier": "SKT",
    "client_reference": "attempt_93a7",
    "state": "SERVER_GENERATED_RANDOM_STATE",
    "expires_at": "2026-09-16T03:05:00.000Z",
    "verified_at": "2026-09-16T03:01:00.000Z"
  }
}
```

`data`에는 조회 응답의 `created_at`, `server_time`이 포함되지 않습니다. `carrier`는 `null`일 수 있으며, 이전 이벤트에는 필드가 없을 수 있습니다.

## 서명 검증

이벤트 ID는 `Veri-Event-ID`, 서명은 `Veri-Signature: t=UNIX_SECONDS,v1=HEX_HMAC` 헤더로 전달됩니다.

1. JSON 파싱 전 **원본 HTTP 본문 바이트**를 확보합니다.
2. 서명 시각과 서버 시각의 차이가 5분 이내인지 확인합니다.
3. `timestamp + "." + raw_body`에 대해 HMAC-SHA256을 계산합니다. 키는 `whsec_` 접두사를 포함한 전체 문자열입니다.
4. 상수 시간 비교로 서명을 확인한 뒤 JSON을 파싱합니다.
5. 헤더와 본문의 이벤트 ID, 이벤트 종류와 상태, 서버에 저장한 인증 시도를 대조합니다.

[Node.js 서명 검증 함수 다운로드](https://sendwich.kr/docs/downloads/webhook-signature.mjs)

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(rawBody, signature, secrets, now = Date.now()) {
  if (!Buffer.isBuffer(rawBody) || rawBody.length > 65536) throw new Error('Invalid body.');
  const match = /^t=(\d{1,12}),v1=([a-f0-9]{64})$/.exec(signature || '');
  if (!match || Math.abs(now / 1000 - Number(match[1])) > 300) {
    throw new Error('Invalid or expired signature.');
  }
  const provided = Buffer.from(match[2], 'hex');
  const keys = Array.isArray(secrets) ? secrets : [secrets];
  const valid = keys.filter(key => typeof key === 'string' && key.startsWith('whsec_')).some(key => {
    const expected = createHmac('sha256', key)
      .update(match[1] + '.').update(rawBody).digest();
    return timingSafeEqual(provided, expected);
  });
  if (!valid) throw new Error('Invalid signature.');
  const event = JSON.parse(rawBody.toString('utf8'));
  const statuses = ['verified', 'expired', 'cancelled'];
  if (!event || typeof event.id !== 'string' || !event.id.startsWith('evt_') ||
      !event.data || !statuses.includes(event.data.status) ||
      event.type !== 'verification.' + event.data.status) {
    throw new Error('Invalid event.');
  }
  // The receiver must still compare Veri-Event-ID to event.id, bind event.data
  // to its stored attempt, check live/mode/state/reference, and deduplicate in DB.
  return event;
}
```

Express 등에서 JSON 미들웨어를 사용한다면 이 경로에서는 파싱 전에 원문을 받아야 합니다. `JSON.stringify(req.body)`로 재구성하면 원문이 달라져 서명 검증에 실패할 수 있습니다. 수신 본문의 크기도 제한합니다.

## 결과 저장과 중복 처리

서명 검증 후 `data.id`, `state`, `client_reference`, `environment`, `mode`를 서버에 저장한 시도와 대조합니다.

이벤트 ID에 고유 제약을 두고 이벤트 저장과 후속 작업 예약을 하나의 트랜잭션으로 처리합니다. 저장이 완료되면 `2xx`를 반환하고, 역할 부여나 이메일 발송은 별도 작업으로 실행합니다. 이미 저장한 이벤트는 중복 실행 없이 `2xx`를 반환합니다. 저장에 실패한 경우에는 오류 응답을 반환해 재전송을 받습니다.

API 조회와 웹훅을 함께 사용한다면 인증 ID에도 중복 처리 방지를 적용해야 합니다.

## 재전송

전송 실패 시 5초부터 최대 1시간까지 대기 시간을 늘리며 재시도합니다. 전송 기한은 24시간, 최대 시도 횟수는 20회입니다. 재시도 시 서명 시각은 갱신되며 이벤트 ID와 본문은 유지됩니다.

대시보드의 **웹훅 → 전송 내역**에서 상태와 시도 횟수를 확인할 수 있습니다. 전송이 종료된 이벤트의 결과는 [조회 API](https://sendwich.kr/docs/api/retrieve-and-cancel/)로 확인합니다.

## 설정 변경과 키 교체

수신 URL, 사용 여부, 서명 키는 인증 생성 시점의 설정을 따릅니다. 변경 후에도 기존 인증의 이벤트와 재전송에는 이전 설정이 사용됩니다. 키를 교체할 때는 기존 인증이 종료된 후 최대 24시간까지 이전 키로도 서명을 검증할 수 있어야 합니다.
