웹훅
인증 결과 수신, 서명 검증, 중복 이벤트 처리
수신 설정#
프로젝트의 웹훅에서 수신 URL을 등록하고 사용을 켠 뒤 저장합니다. 수신 주소는 https://shop.example.com/webhooks/sendwich와 같이 서비스 서버에 구현합니다.
실제 SMS 프로젝트에는 외부에서 접근 가능한 HTTPS 주소와 443 포트가 필요합니다. localhost, 사설망 주소, 리디렉션은 지원하지 않습니다.
처음 저장할 때 표시되는 whsec_… 서명 키를 서버에 보관합니다. 설정은 저장 후 생성한 인증부터 적용됩니다.
이벤트#
| 이벤트 | 상태 |
|---|---|
verification.verified |
인증 성공 |
verification.expired |
인증 만료 |
verification.cancelled |
인증 취소 |
각 인증은 하나의 최종 이벤트를 생성합니다. 같은 이벤트가 재전송될 수 있으므로 중복 실행을 방지해야 합니다.
{
"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 헤더로 전달됩니다.
- JSON 파싱 전 원본 HTTP 본문 바이트를 확보합니다.
- 서명 시각과 서버 시각의 차이가 5분 이내인지 확인합니다.
timestamp + "." + raw_body에 대해 HMAC-SHA256을 계산합니다. 키는whsec_접두사를 포함한 전체 문자열입니다.- 상수 시간 비교로 서명을 확인한 뒤 JSON을 파싱합니다.
- 헤더와 본문의 이벤트 ID, 이벤트 종류와 상태, 서버에 저장한 인증 시도를 대조합니다.
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로 확인합니다.
설정 변경과 키 교체#
수신 URL, 사용 여부, 서명 키는 인증 생성 시점의 설정을 따릅니다. 변경 후에도 기존 인증의 이벤트와 재전송에는 이전 설정이 사용됩니다. 키를 교체할 때는 기존 인증이 종료된 후 최대 24시간까지 이전 키로도 서명을 검증할 수 있어야 합니다.
