본문으로 건너뛰기
Sursa Developers

웹훅 수신하기

웹훅은 Sursa에서 일어난 변경을 바로 회사의 시스템에 알려 줘요. 카드가 게시, 검증, 보관되거나 질문에 답변하지 못한 경우 등이에요. Sursa는 등록한 주소로 이벤트를 담은 HTTPS POST 요청을 보내요. 이 가이드에서는 재시도, 순서 바뀜, 중복 상황에서도 올바르게 동작하는 수신 서버를 만들어요.

각 이벤트의 의미와 내용은 웹훅 개요와 API 레퍼런스의 이벤트 카탈로그를 참고하세요.

1. 웹훅 등록하기

관리자는 Sursa의 Administration → Integrations → Webhooks and API(관리 → 연동 → 웹훅)에서 웹훅을 등록하거나, admin 역할과 webhooks:manage 스코프가 있는 키로 POST /v1/webhooks를 호출해 등록해요.

curl -X POST https://api.sursa.com/v1/webhooks \
  -H "Authorization: Bearer $SURSA_ADMIN_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/sursa",
    "description": "Sync verified cards to the help center",
    "events": ["card.published", "card.updated", "card.verified", "card.archived"]
  }'

응답의 secret 필드에 whsec_로 시작하는 서명 시크릿이 담겨요. 시크릿은 한 번만 표시돼요. 수신 서버에서 쓸 수 있도록 SURSA_WEBHOOK_SECRET 같은 환경 변수에 보관하세요.

수신 주소는 https를 사용해야 하고 공개 IP 주소로 확인되어야 해요. 사설망, 루프백 등 예약된 네트워크의 주소는 등록할 때와 전송할 때마다 거부돼요. 자신의 컴퓨터에서 개발하려면 로컬 수신 서버에 공개 HTTPS 주소를 붙여 주는 터널링 서비스로 수신 서버를 외부에 노출하세요.

언제든 POST /v1/webhooks/{webhook_id}/test로 테스트 이벤트를 보낼 수 있어요. 응답에서 수신 서버가 어떻게 응답했는지 확인할 수 있어요.

2. 전송의 형태

POST /sursa HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: evt_0123456789abcdef0123456789abcdef
webhook-timestamp: 1791194400
webhook-signature: v1,l1LLl/aEl4dQq7Geb383KbyNeZgs7VZpiw6iuoJgunI=
X-KB-Signature: t=1791194400,v1=82aa6b27378d89d7d880d17c3fe96268598443ae0e64d059c0c41e94e5d3ffbe
X-KB-Event-Id: evt_0123456789abcdef0123456789abcdef
X-KB-Event: card.verified
X-KB-Delivery: 99812
X-KB-Attempt: 1
X-KB-Sequence: 1042

{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"api_version":"1.60.0","sequence":1042,"object":{"type":"card","id":"c1"},"payload":"full","data":{"card":{"id":"c1"}},"test":false}
헤더의미
webhook-id이벤트 ID. 재시도할 때마다 같아요. 중복을 걸러 낼 때 쓰세요.
webhook-timestamp이 전송 시도에 서명한 시각(Unix 초).
webhook-signatureStandard Webhooks 형식의 서명.
X-KB-Signature같은 전송을 이전 Sursa 형식으로 서명한 값.
X-KB-Event이벤트 유형.
X-KB-Attempt1부터 시작하는 전송 시도 번호.
X-KB-Sequence웹훅의 전송 순서에서 이 전송의 위치.

헤더 이름은 대소문자를 구분하지 않아요. 프레임워크에 따라 X-Kb-Signature나 x-kb-signature로 보일 수 있어요.

본문은 이벤트예요. data는 유형에 따라 달라요. thin 페이로드로 설정된 웹훅은 data 없이 이벤트를 받아요. 이때는 직접 관리하는 키로 API를 통해 객체를 읽으세요.

3. 서명 검증하기

모든 전송은 신뢰하기 전에 서명을 확인하세요. 두 형식 모두 본문의 바이트를 그대로 서명하므로, JSON을 파싱하기 전에 원본 본문을 읽으세요. JSON을 다시 직렬화하면 바이트가 바뀌어 검증에 실패해요.

형식서명 대상 문자열키서명
Standard Webhooks(webhook-signature)<webhook-id>.<webhook-timestamp>.<body>시크릿에서 whsec_ 뒤의 부분을 base64로 디코딩한 값v1, + HMAC-SHA256의 base64
이전 형식(X-KB-Signature)<t>.<body>시크릿 문자열 전체(텍스트 그대로)v1= + HMAC-SHA256의 16진수

둘 중 하나만 검증해도 충분해요. Standard Webhooks 헤더를 권장해요. 같은 검증을 여러 기성 라이브러리에서 쓸 수 있어요. 시크릿을 교체하는 동안에는 각 헤더에 유효한 시크릿마다 서명이 하나씩 담겨요(webhook-signature에서는 공백으로 구분하고, X-KB-Signature에서는 v1=가 반복돼요). 그중 하나라도 일치하면 유효한 전송이에요.

또한 서버 시계와 5분 넘게 차이 나는 타임스탬프는 거부하세요. 그래야 누군가 기록해 둔 전송을 나중에 다시 보내는 것을 막을 수 있어요.

# Check a saved delivery by hand: body.json holds the raw body, the values come from its headers.
SECRET="whsec_…"; WEBHOOK_ID="evt_…"; TIMESTAMP="1791194400"

# Standard Webhooks: compare with the value after "v1," in webhook-signature
KEY=$(printf '%s' "${SECRET#whsec_}" | tr '_-' '/+')
while [ $(( ${#KEY} % 4 )) -ne 0 ]; do KEY="$KEY="; done
KEY_HEX=$(printf '%s' "$KEY" | openssl base64 -d -A | xxd -p | tr -d '\n')
{ printf '%s.%s.' "$WEBHOOK_ID" "$TIMESTAMP"; cat body.json; } \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | openssl base64 -A

# Earlier format: compare with the value after "v1=" in X-KB-Signature
{ printf '%s.' "$TIMESTAMP"; cat body.json; } | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1

서명 검증 코드 테스트하기

실제 전송에 연결하기 전에 다음 값으로 코드를 실행해 보세요. 시계를 이 타임스탬프에 맞추면 두 검증 모두 통과해야 하고, 본문의 바이트 하나를 바꾸면 실패해야 해요.

입력값
시크릿whsec_test_secret_0123456789abcdef
webhook-idevt_0123456789abcdef0123456789abcdef
webhook-timestamp와 t1791194400
본문{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"data":{"card":{"id":"c1"}}}
webhook-signaturev1,+xxWSXH6weQJ/MVg4+N78a1Dj4y/AQ4p94+PRNzpECg=
X-KB-Signaturet=1791194400,v1=ce26a19cb63cd3ae620217bf7688e1df1044fa7d59d9ae148d215f0292b768dc

4. 빠르게 응답하고 처리는 나중에 하기

Sursa는 응답을 최대 10초 동안 기다려요. 2xx 상태는 모두 전송 성공을 뜻해요. 리디렉션, 타임아웃, 끊긴 연결을 포함한 그 밖의 모든 경우는 실패로 보고 재시도해요.

따라서 수신 서버는 서명 검증, 이벤트 기록, 응답만 해야 해요. 실제 처리는 그 뒤에 직접 운영하는 대기열에서 하세요.

// Node.js 20+, no framework. verifyStandard and verifyLegacy come from step 3.
import http from "node:http";

const SECRET = process.env.SURSA_WEBHOOK_SECRET;

http
  .createServer(async (req, res) => {
    const parts = [];
    for await (const part of req) parts.push(part);
    const body = Buffer.concat(parts); // the raw bytes, before any JSON parsing

    const valid = req.headers["webhook-signature"]
      ? verifyStandard(SECRET, req.headers, body)
      : verifyLegacy(SECRET, req.headers["x-kb-signature"], body);
    if (!valid) {
      res.writeHead(400).end();
      return;
    }

    const event = JSON.parse(body);
    await saveEventOnce(event); // insert into your table or queue, keyed by event.id; ignore a duplicate
    res.writeHead(204).end();
  })
  .listen(3000);

이벤트를 안전하게 저장한 뒤에만 2xx로 응답하세요. 먼저 응답한 뒤 서버가 중단되면, Sursa는 이미 전송된 것으로 처리했기 때문에 그 이벤트를 잃게 돼요.

5. 재시도

실패한 전송은 간격을 늘려 가며 재시도해요. 약 1분, 5분, 30분, 2시간, 5시간, 10시간, 24시간, 24시간 뒤에 다시 보내서 거의 3일에 걸쳐 9번 시도해요. 여러 웹훅의 재시도가 같은 순간에 몰리지 않도록 각 간격은 최대 20%까지 달라져요.

  • Retry-After와 함께 429나 503으로 응답하면 다음 시도가 최소 그 시각까지, 최대 24시간 뒤로 미뤄져요. 시스템이 과부하 상태이거나 점검 중일 때 쓰세요.
  • 410 Gone으로 응답하면 웹훅이 비활성화돼요. 그 주소로 더 이상 전송을 받지 않으려는 경우에만 보내세요.
  • 10번 연속으로 시도가 실패하면 웹훅이 일시 중지되고 조직 관리자에게 알림이 가요. 이벤트는 계속 쌓이며, 관리자 화면에서나 POST /v1/webhooks/{webhook_id}/resume으로 웹훅을 다시 시작하면 순서대로 전송돼요.
  • 마지막 시도 뒤에는 전송이 exhausted로 표시돼요. 직접 다시 보낼 수 있어요.

웹훅의 전송 기록은 각 시도, 수신 서버의 응답 코드, 소요 시간과 함께 GET /v1/webhooks/{webhook_id}/deliveries와 관리자 화면에서 볼 수 있어요. POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver는 전송 하나를 다시 보내요. 재전송에도 같은 webhook-id가 붙어요.

6. 순서와 중복

  • 순서. 한 웹훅으로 가는 전송은 sequence 순서대로 엄격하게 하나씩 이어져요. 한 전송을 재시도하는 동안 뒤의 전송은 기다려요. 서로 다른 웹훅은 서로를 기다리지 않아요.
  • 중복. 수신 서버의 응답이 돌아오는 길에 유실된 경우처럼 같은 이벤트가 두 번 이상 도착할 수 있어요. 처리한 모든 이벤트의 webhook-id(본문의 id와 같아요)를 저장하고, 이미 본 이벤트는 건너뛰세요.
  • 상태로 생각하기. 같은 객체의 변경은 created_at이나 sequence를 저장해 둔 값과 비교하고, 이미 가진 상태보다 오래된 이벤트는 무시하세요. 확실하지 않으면 API로 현재 객체를 읽으세요. 이벤트는 무언가가 바뀌었다는 사실을 알려 주고, API는 지금 어떤 상태인지를 알려 줘요.

7. 이벤트 피드와 대조하기

모든 이벤트는 웹훅의 구독 여부와 관계없이 이벤트 피드에도 30일 동안 보관돼요. 수신 서버가 재시도 기간보다 오래 중단되었거나, 놓친 이벤트가 없는지 확인하고 싶다면 admin 역할과 webhooks:manage 스코프가 있는 키로 피드를 읽으세요.

curl -G https://api.sursa.com/v1/webhook-events \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  --data-urlencode "type=card.*" \
  --data-urlencode "created_since=2026-10-20T00:00:00Z" \
  --data-urlencode "limit=200"

피드의 이벤트는 전송과 같은 id와 형태를 가지며, 전송 필드인 sequence, payload, test만 없어요. 수신 서버가 이미 본 이벤트를 건너뛰므로, 대조는 1시간에 한 번처럼 원하는 만큼 자주 실행해도 돼요.

GET /v1/webhook-events/{event_id}는 ID로 이벤트 하나를 읽어요.

8. 시크릿 교체하기

POST /v1/webhooks/{webhook_id}/rotate-secret는 새 시크릿을 발급하고, 이전 시크릿을 유예 기간(기본 24시간, 최대 7일) 동안 유효하게 유지해요. 유예 기간에는 모든 전송이 두 시크릿으로 각각 서명돼요.

  1. 시크릿을 교체하고, 수신 서버에 새 시크릿을 이전 시크릿과 함께 저장하세요.
  2. 두 시크릿 중 하나로 검증되면 전송을 받아들이세요.
  3. 유예 기간이 끝나면 이전 시크릿을 지우세요.

이전 시크릿이 유출되었다면 기다리지 말고 POST /v1/webhooks/{webhook_id}/revoke-previous-secret으로 유예 기간을 즉시 끝내거나, "overlap_hours": 0으로 교체하세요.

체크리스트

  • 원본 본문으로 서명 중 하나를 검증하고 타임스탬프를 확인하세요.
  • webhook-id를 키로 이벤트를 저장한 뒤 10초 안에 2xx로 응답하세요.
  • 중복된 이벤트와 오래된 이벤트는 건너뛰고, 중요한 경우에는 API로 현재 객체를 읽으세요.
  • Sursa가 전송을 늦춰야 할 때는 Retry-After와 함께 429나 503으로 응답하세요. 실수로 410으로 응답하지 마세요.
  • 모르는 이벤트 유형과 필드는 건너뛰세요.
  • 정해 둔 주기로 이벤트 피드와 대조하세요.