개발자 문서

고객 웹훅 받기

발행 결과와 계정 연결 해제 이벤트를 고객 서버의 HTTPS 주소로 보냅니다. 이벤트는 한 번 이상 도착할 수 있으므로 서명을 확인한 뒤 이벤트 ID로 중복 처리를 막아 주세요.

전달 계약

본문은 JSON입니다. version, id, type, workspaceId, occurredAt, status와 이벤트별 postId 또는 connectionId가 포함됩니다.

이벤트 종류는 post.published, post.partial,post.failed, connection.disconnected입니다. 글 내용이나 채널 토큰은 보내지 않습니다.

2xx는 성공으로 끝냅니다. 408·429·5xx·연결 실패·시간 초과는 제한된 횟수만 다시 보내고, 다른 3xx·4xx는 최종 실패로 기록합니다. 응답을 받지 못했다면 고객 서버가 이미 처리했어도 다시 도착할 수 있습니다.

1. 원본 본문으로 서명 확인

등록할 때 한 번 표시된 비밀값으로 X-MB-Timestamp(Unix 초), X-MB-Event-Id, 원본 본문 바이트를 순서대로 HMAC-SHA256 검증합니다. 서명 형식은 v1=<hex>이며 허용 시각 차이는 5분입니다. JSON을 다시 문자열로 만들면 공백이나 필드 순서 때문에 서명이 달라질 수 있습니다.

Node.js 서명 검증 예시

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody는 HTTP 요청에서 읽은 원본 Buffer입니다. JSON 재직렬화는 금지합니다.
export function verifyMbWebhook(headers, rawBody, secret, now = Date.now()) {
  const eventId = headers["x-mb-event-id"];
  const seconds = headers["x-mb-timestamp"];
  const signature = headers["x-mb-signature"];
  if (typeof eventId !== "string" || typeof seconds !== "string" ||
      typeof signature !== "string" || !/^[0-9]{1,12}$/.test(seconds) ||
      !/^v1=[a-f0-9]{64}$/.test(signature)) return false;
  const timestamp = Number(seconds);
  if (!Number.isSafeInteger(timestamp) ||
      Math.abs(now - timestamp * 1000) > 5 * 60 * 1000) return false;
  const expected = createHmac("sha256", secret)
    .update(seconds + "." + eventId + ".", "utf8")
    .update(rawBody).digest();
  const actual = Buffer.from(signature.slice(3), "hex");
  return timingSafeEqual(expected, actual);
}

2. 이벤트 ID를 한 번만 처리

같은 이벤트를 다시 보낼 때는 id와 본문이 그대로이고 전송 시각과 서명만 새로 만듭니다. 수동 재전송도 같은 ID를 사용합니다. 서명과 본문 ID를 확인한 뒤 고객 DB에 (workspace_id, event_id) 고유 키를 만들고, 새 ID 등록과 실제 업무 변경을 한 트랜잭션에서 확정해 주세요.

PostgreSQL 중복 제거 키

create table received_mb_events (
  workspace_id uuid not null,
  event_id uuid not null,
  event_type text not null,
  received_at timestamptz not null default now(),
  primary key (workspace_id, event_id)
);

중복에도 안전한 처리 흐름

// 위의 verifyMbWebhook을 먼저 실행합니다. rawBody는 원본 Buffer입니다.
// sql은 PostgreSQL 클라이언트입니다. applyEvent는 같은 DB 트랜잭션에서 실행합니다.
if (!verifyMbWebhook(headers, rawBody, secret)) return { status: 401 };
let event;
try { event = JSON.parse(rawBody.toString("utf8")); }
catch { return { status: 400 }; }
if (event.version !== 1 || event.id !== headers["x-mb-event-id"] ||
    typeof event.workspaceId !== "string" || typeof event.type !== "string") {
  return { status: 400 };
}
try {
  await sql.begin(async (tx) => {
    const inserted = await tx`
      insert into received_mb_events(workspace_id, event_id, event_type)
      values (${event.workspaceId}::uuid, ${event.id}::uuid, ${event.type})
      on conflict do nothing returning event_id
    `;
    if (inserted.length === 0) return; // 이미 처리한 이벤트: 업무 작업도 반복하지 않음
    await applyEvent(tx, event);       // 예: 내부 발행 상태 갱신
  });
  return { status: 204 };             // 중복 이벤트도 204
} catch {
  return { status: 503 };             // 트랜잭션 롤백: 이후 재전송 가능
}

중복이면 업무 변경 없이 2xx를 반환합니다. 처리에 실패하면 트랜잭션을 되돌리고 5xx를 반환합니다. 외부 결제나 이메일 같은 작업은 별도의 멱등 키 또는 아웃박스로 보호해야 합니다. 오래된 이벤트를 수동 재전송할 수 있으므로 중복 제거 키를 지우면 같은 업무가 다시 실행될 수 있습니다.

3. 운영 중 확인

  • 서명 비밀값은 서버에만 저장하고 로그나 브라우저에 넣지 않습니다.
  • 비밀값을 바꾼 직후 이미 전송 중이던 요청은 이전 값으로 도착할 수 있습니다. 교체 목적에 맞는 제한된 전환 기간을 정하거나, 유출 대응이라면 이전 값을 즉시 거부합니다.
  • 웹훅 대시보드에서 시도 이력과 실패 원인을 확인하고, 최종 실패 건만 다시 보냅니다. 재전송 전 고객 서버가 해당 이벤트 ID를 이미 처리했는지 먼저 확인합니다.
웹훅 관리로 이동