웹훅

웹훅은 동의 철회·등급 변경 같은 이벤트를 파트너 서버로 자동 알림하는 방식입니다. 엔드포인트 등록은 운영팀을 통해 이루어지며, 등록 시 서명 시크릿이 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-Signaturet=<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 파싱 후 재직렬화한 문자열은 바이트가 달라져 검증에 실패합니다.

수신 규칙과 재시도

테스트

운영팀에 테스트 이벤트 발송을 요청하면(관리자 콘솔의 test 기능) 실제 서명이 붙은 합성 이벤트가 등록된 URL로 전달됩니다. 최근 50건의 전달 기록(성공/실패/재시도)도 운영팀을 통해 확인할 수 있습니다.