웹훅
웹훅은 동의 철회·등급 변경 같은 이벤트를 파트너 서버로 자동 알림하는 방식입니다. 엔드포인트 등록은 운영팀을 통해 이루어지며, 등록 시 서명 시크릿이 1회 평문으로 발급됩니다.
이벤트 종류 (4종)
| eventType | 발생 시점 | 필요 동의 카테고리 |
|---|---|---|
consent.revoked | 사용자가 점수 공유 동의를 철회한 때. 철회 후에도 이 이벤트만 발송됨 | 없음(카테고리 무관 발송) |
score.updated | 점수가 재산출되어 저장된 때 | monitoring |
tier.changed | 등급이 바뀐 때. direction은 UP | DOWN만 제공 | monitoring |
attestation.expiring | 신용등급 증명서(어테스테이션)가 7일 내 만료 예정일 때(일 1회 스캔) | — |
점수 원값은 웹훅에 절대 포함되지 않습니다. 웹훅은 "무언가 바뀌었다"는
신호일 뿐이며, 실제 값은 동의가 유효한 동안 API로 조회해야 합니다. 이벤트는 해당 pairwise 주체의 동의가
ACTIVE인 클라이언트의 ACTIVE 엔드포인트에만 발송됩니다(
consent.revoked는 철회 직전 동의를
보유했던 클라이언트에 발송).페이로드
{
"deliveryId": "d2f1a3b4-5c6d-7e8f-9a0b-example",
"eventType": "tier.changed",
"occurredAt": "2026-08-24T02:15:00Z",
"data": {
"pairwiseSub": "b41c9d2f-8e77-4a10-9c3e-example",
"direction": "DOWN"
}
}
서명 검증
모든 웹훅 요청에는 두 개의 헤더가 붙습니다.
| 헤더 | 내용 |
|---|---|
X-HACS-Signature | t=<unixSeconds>,v1=<hex hmacSHA256(secret, t + "." + rawBody)> |
X-HACS-Delivery-Id | 전달 고유 ID — 중복 수신(at-least-once) 멱등 처리에 사용 |
검증 절차: ① 헤더에서 t와 v1을 파싱 ② t + "." + rawBody를
시크릿으로 HMAC-SHA256 계산 ③ 결과 hex를 v1과 상수시간 비교
④ t가 현재 시각에서 5분 이상 벗어나면 거부(재전송 공격 방지).
Java 예제
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
public final class HacsWebhookVerifier {
private static final long TOLERANCE_SECONDS = 300;
public static boolean verify(String signatureHeader, String rawBody, String secret) {
long t = -1;
String v1 = null;
for (String part : signatureHeader.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length != 2) continue;
if ("t".equals(kv[0])) t = Long.parseLong(kv[1]);
if ("v1".equals(kv[0])) v1 = kv[1];
}
if (t < 0 || v1 == null) return false;
if (Math.abs(System.currentTimeMillis() / 1000 - t) > TOLERANCE_SECONDS) return false;
String signedPayload = t + "." + rawBody;
String expected = hmacSha256Hex(secret, signedPayload);
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
v1.getBytes(StandardCharsets.UTF_8));
}
private static String hmacSha256Hex(String secret, String payload) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) sb.append(String.format("%02x", b));
return sb.toString();
} catch (Exception e) {
throw new IllegalStateException("HMAC failure", e);
}
}
}
TypeScript (Node.js) 예제
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyHacsSignature(
signatureHeader: string,
rawBody: string,
secret: string,
): boolean {
let t = -1;
let v1 = "";
for (const part of signatureHeader.split(",")) {
const [k, v] = part.trim().split("=", 2);
if (k === "t") t = Number(v);
if (k === "v1") v1 = v ?? "";
}
if (t < 0 || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
HMAC은 반드시 가공하지 않은 원문 바디(raw body)로 계산해야 합니다.
JSON 파싱 후 재직렬화한 문자열은 바이트가 달라져 검증에 실패합니다.
수신 규칙과 재시도
- 2xx 응답을 3초 안에 돌려주세요(타임아웃 3초). 무거운 처리는 큐에 넣고 즉시 응답.
- 전달은 at-least-once입니다 —
X-HACS-Delivery-Id로 중복을 걸러내세요. - 실패(비 2xx/타임아웃) 시 재시도 사다리: 1분 → 5분 → 30분 → 2시간, 소진 시 해당 전달은 EXHAUSTED 처리.
- 엔드포인트가 연속 10회 실패하면 자동 비활성화(DISABLED)됩니다. 재활성화는 운영팀에 요청하세요(연속 실패 카운터 리셋).
- 엔드포인트 URL은 https만 허용되며, 사설/루프백/링크로컬 IP로 resolve되는 호스트는 거부됩니다.
테스트
운영팀에 테스트 이벤트 발송을 요청하면(관리자 콘솔의 test 기능) 실제 서명이 붙은 합성 이벤트가 등록된 URL로 전달됩니다. 최근 50건의 전달 기록(성공/실패/재시도)도 운영팀을 통해 확인할 수 있습니다.