sayren Docs

웹훅

이벤트 수신, 서명 검증, 전송 실패와 재시도·재전송, 구독 정지와 재개

주문·클레임·문의·리뷰·재고 이벤트가 생기면 등록한 HTTPS 주소로 POST 요청을 보냅니다. 구독은 상점당 10개까지입니다 (넘으면 409 WEBHOOK_LIMIT_EXCEEDED).

상점 플랫폼에서 등록하기

상점 플랫폼에서 개발자 › 이벤트 › 웹훅으로 이동합니다(소유자·관리자).

  1. **[엔드포인트 추가]**를 누릅니다.
  2. 엔드포인트 URL(https://)과 받을 이벤트를 고르고 **[추가]**를 누릅니다.
  3. 서명 시크릿을 복사해 수신 서버에 넣습니다. 전체 값은 이때만 보입니다.
  4. 엔드포인트 상세의 **[테스트 전송]**으로 수신을 확인합니다.

상세에서 편집·삭제, 시크릿 재발급, 전송 기록과 재전송을 합니다. API는 관리 API 개요와 Store SDK를 참고하십시오(스코프 webhook:r·webhook:rw).

요청 형식

POST /hooks/sayren HTTP/1.1
Content-Type: application/json
User-Agent: Sayren-Webhooks/1.0
Sayren-Signature: t=1789804800,v1=5f2b…
Sayren-Event-Id: evt_01J…
Sayren-Event-Type: ORDER.PAID
Sayren-Delivery-Id: whd_01J…

{
  "id": "evt_01J…",
  "type": "ORDER.PAID",
  "createdAt": "2026-09-19T05:20:00.000Z",
  "storeId": "store_…",
  "sequence": 3,
  "data": { "orderId": "ord_…", "status": "PAID", "testPayment": false }
}

Sayren-Event-Id·Sayren-Event-Type은 본문의 id·type과 같고, Sayren-Delivery-Id는 전송 기록 ID입니다. 본문에는 리소스 ID와 상태만 담기고 구매자 개인정보(이름·연락처·배송지)는 없습니다. 상세는 data의 ID로 관리 API를 조회하십시오. 예외로 ORDER.PAID의 itemOptions는 구매자가 직접 입력 항목에 적은 글자를 그대로 싣습니다. 개인정보가 섞일 수 있는 값으로 다루십시오.

sequence는 리소스별 순번입니다. 같은 주문(ORDER.*)·클레임(CLAIM.*)·문의·리뷰·상품(PRODUCT.*)에 이벤트가 생길 때마다 커집니다. 구독하지 않은 이벤트 타입, 구독 전·정지 중의 이벤트도 번호를 쓰므로 받는 쪽에서는 번호가 비는 것이 정상입니다. 빈 번호를 기다리지 마십시오. 이 필드가 생기기 전의 이벤트와 테스트 전송에는 없습니다.

구독 대상은 이벤트를 보낼 준비를 하는 시점에 정합니다. 구독을 만들거나 이벤트 타입·주소를 바꾼 직후에는 그 직전에 생긴 이벤트가 바뀐 설정으로 올 수 있습니다.

이벤트

타입발생 시점data
ORDER.PAID결제 완료orderId orderNo status testPayment. 추가 선택·직접 입력이 있는 주문이면 itemOptions(주문 상품별 optionSelections·customInputs)도
ORDER.CONFIRMED발주 확인위와 같음
ORDER.DISPATCHED발송 처리위와 같음
ORDER.DELIVERED배송 완료(셀러 처리·자동 배송완료). 비실물 상품은 제공 완료위와 같음. 비실물 제공이면 orderItemId fulfillmentSnapshot도
ORDER.FULFILLED배송 없는 상품 제공(판매자 제공 처리)orderId orderNo orderItemId status fulfillmentSnapshot fulfilledAt testPayment
ORDER.PURCHASE_DECIDED구매 확정(구매자·자동 구매확정)위와 같음
ORDER.ADDRESS_CHANGE_REQUESTED배송지 변경 요청. 구독만 되고 아직 발송되지 않음위와 같음
ORDER.CANCELED판매자 취소(일부 수량 포함)orderId orderNo orderItemId status itemStatus reason quantity canceledQuantity refundAmount deliveryFeeRefundAmount deliveryFeeChargeAmount testPayment
CLAIM.CANCEL_REQUESTED취소 요청claimId claimNo orderId orderItemId claimType status quantity refundAmount deliveryFeeRefundAmount deliveryFeeChargeAmount deductAmount testPayment
CLAIM.RETURN_REQUESTED반품 요청위와 같음
CLAIM.EXCHANGE_REQUESTED교환 요청위와 같음
CLAIM.COMPLETED클레임 처리 완료. 교환은 셀러가 교환 완료를 누를 때위와 같음
INQUIRY.CREATED상품 문의 등록(1:1 문의 제외)inquiryId productId
REVIEW.CREATED리뷰 등록reviewId productId rating
PRODUCT.OUT_OF_STOCK판매 단위(옵션 조합)의 재고가 0이 되는 순간 한 번productId variantId
ORDER.FLOW_NOTIFICATION상점 주문 흐름의 알림 블록이 실행될 때(구매자 알림을 직접 보낼 때 씁니다)orderId orderItemIds event flowId flowVersion state
ORDER.FLOW_STATE_CHANGED주문상품의 흐름 상태(단계)가 바뀔 때orderId orderItemIds flowId flowVersion from to label coreStatus changedAt
  • orderNo(주문번호)·claimNo(클레임번호)는 숫자 번호입니다(주문·상품 번호). 이 필드가 없는 이전 이벤트는 null로 읽습니다.
  • ORDER.*의 status는 취소되지 않은 주문 상품 중 가장 덜 진행된 상태입니다(일부만 발송하면 DISPATCHED 이벤트에 CONFIRMED). 발주 확인·발송·배송 완료·구매 확정은 주문 상품마다 발생해 한 주문에 여러 번 올 수 있습니다.
  • testPayment는 테스트 결제 주문이면 true입니다. 실주문만 처리하려면 이 값으로 거르십시오. 2026-09-19에 추가됐으므로 data를 strict로 검증한다면 허용하십시오. Store SDK의 webhookEventSchema는 없는 값을 false로 읽습니다.
  • CLAIM.* 금액: quantity는 대상 수량, refundAmount는 환불액(배송비 환불 포함, 배송비 차감·추가 차감 제외), deliveryFeeRefundAmount는 그중 배송비, deliveryFeeChargeAmount는 뺀 배송비(반품·교환 배송비 + 조건부 무료배송 미달 차감), deductAmount는 셀러가 승인에 넣은 추가 차감입니다. 배송비 차감과 추가 차감은 따로 오므로 환불액을 되짚을 때 둘 다 빼십시오.
  • 요청 이벤트의 금액은 예상값이고 확정값은 CLAIM.COMPLETED입니다. 환불이 없는 교환은 refundAmount가 null입니다. 다섯 금액 필드는 2026-09-26에 추가됐고, SDK는 없는 값을 0(refundAmount는 null)으로 읽습니다. 계산 규칙은 환불 정책과 배송비를 참고하십시오.
  • variantId는 주문 상품의 optionId와 같은 값입니다. ID 형식은 고정이 아니니 접두어로 판별하지 마십시오.
  • ORDER.CANCELED는 셀러가 발송 전 주문 상품을 판매자 취소할 때 옵니다. 구매자가 신청한 취소는 CLAIM.*로 옵니다. quantity는 이번에 취소한 수량, canceledQuantity는 누적 취소 수량입니다. 일부만 취소하면 itemStatus는 원래 상태이고, 전 수량을 취소하면 CANCELED입니다. status는 취소 뒤 주문 대표 상태입니다. reason은 취소 사유 코드 (OUT_OF_STOCK·PRICE_ERROR·DELIVERY_IMPOSSIBLE·ETC)이고 셀러가 적은 상세 사유는 싣지 않습니다. 사유는 늘어날 수 있으니 모르는 값은 ETC처럼 다루십시오. refundAmount는 수량 비율 상품 금액 + 배송비 환불(deliveryFeeRefundAmount) − 조건부 무료배송 미달 차감(deliveryFeeChargeAmount)이고, 결제사 환불 결과는 결제 내역으로 확인하십시오. 이 이벤트는 2026-09-27에 추가됐습니다. type을 닫힌 목록으로 검증한다면 허용하십시오.
  • ORDER.FULFILLED는 배송 없는 상품(MANUAL)을 판매자가 제공 처리할 때 옵니다. 같은 순간 ORDER.DELIVERED도 함께 오므로 둘 다 구독했다면 한쪽만 처리하십시오. 비실물 제공으로 보낸 ORDER.DELIVERED에는 orderItemId와 fulfillmentSnapshot(type이 MANUAL)이 실리고, 택배 배송완료에는 두 필드가 없습니다. fulfillmentSnapshot은 주문 상품 응답의 fulfillmentSnapshot과 같은 주문 시점 이행입니다. 판매자가 남긴 이용 안내는 주문 상품의 fulfillment.note로 조회하십시오. type은 값이 늘어날 수 있습니다. 이 이벤트는 2026-09-28에 추가됐습니다.
  • 유형 축소: 비실물 유형이 MANUAL 하나로 줄면서 ORDER.FULFILLED의 fulfillment.mode가 없어졌습니다(@sayren/store-sdk 0.20.0). 그 전에 만들어진 이벤트는 재시도·수동 재전송에서 fulfillment.mode나 옛 유형 값(DOWNLOAD·CODE·SERVICE·PICKUP)을 실을 수 있습니다. SDK는 옛 유형을 OTHER로 읽고 모르는 필드는 버립니다. 자세한 흐름은 비실물 상품 판매하기에 있습니다.
  • 배송완료·구매확정은 주문 처리하기를 참고하십시오.

서명 검증

Sayren-Signature는 t=<unix 초>,v1=<hex>입니다. v1은 구독 시크릿을 키로 {t}.{원문 본문}을 HMAC-SHA256한 값입니다.

  1. t가 현재 시각과 5분 넘게 차이 나면 거부합니다(재전송 공격 방지).
  2. 직접 계산한 서명과 v1 값 중 하나가 일치하는지 상수 시간 비교로 확인합니다. ===는 응답 시간으로 서명을 추측할 단서가 됩니다.
  3. 받은 바이트 그대로의 본문으로 검증합니다. 파싱 뒤 다시 직렬화하면 서명이 맞지 않습니다. 파싱은 검증 뒤에 합니다.

Node.js

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

const TOLERANCE_SEC = 5 * 60;

export function verifySayrenSignature(rawBody: string, header: string, secret: string) {
  const pairs = header.split(",").map((part) => part.trim().split("="));
  const t = pairs.find(([key]) => key === "t")?.[1];
  const signatures = pairs.filter(([key]) => key === "v1").map(([, value]) => value);
  if (!t || signatures.length === 0) return false;

  const timestamp = Number(t);
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SEC) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return signatures.some((signature) => {
    const received = Buffer.from(signature, "hex");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

// Express: 이 경로만 원문으로 받습니다
app.post("/hooks/sayren", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  if (!verifySayrenSignature(rawBody, req.get("Sayren-Signature") ?? "", process.env.SAYREN_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  queue.enqueue(JSON.parse(rawBody)); // 처리는 비동기로 넘기고
  res.sendStatus(200); // 바로 응답합니다
});

Store SDK

@sayren/store-sdk/server의 verifyWebhookSignature가 같은 검증을 합니다. Web Crypto API라 Node.js·Deno·Cloudflare Workers에서 씁니다. 서버 코드에서만 import하십시오.

import { parseWebhookEvent } from "@sayren/store-sdk";
import { verifyWebhookSignature } from "@sayren/store-sdk/server";

export async function handleWebhook(request: Request) {
  const rawBody = await request.text();
  const result = await verifyWebhookSignature({
    payload: rawBody,
    header: request.headers.get("Sayren-Signature"),
    secret: process.env.SAYREN_WEBHOOK_SECRET, // 배열이면 여러 시크릿 허용
    // toleranceSec: 300 (기본값)
  });
  if (!result.ok) {
    // result.reason: MISSING_HEADER | MALFORMED_HEADER | TIMESTAMP_OUT_OF_TOLERANCE | SIGNATURE_MISMATCH
    return new Response(null, { status: 400 });
  }
  const parsed = parseWebhookEvent(JSON.parse(rawBody));
  if (!parsed.known) {
    // 이 SDK 버전이 모르는 이벤트 타입입니다. 처리하지 않고 200으로 받으십시오
    return new Response(null, { status: 200 });
  }
  const event = parsed.event; // event.type으로 좁혀 씁니다
  return new Response(null, { status: 200 });
}

parseWebhookEvent는 type을 먼저 보고, 아는 타입은 그 타입의 형태로 엄격하게 검증합니다(틀리면 예외). SDK가 모르는 새 타입은 known: false로 돌려주므로 이벤트 종류가 늘어나도 수신 코드가 깨지지 않습니다.

시크릿 교체

상점 플랫폼 [재발급] 또는 POST /webhooks/{webhookId}/rotate-secret으로 바꾸면 기존 시크릿이 24시간 더 유효합니다. 그동안은 두 서명이 모두 실립니다(새 시크릿 서명이 먼저). 위 코드는 v1 하나만 맞으면 통과하므로 24시간 안에 수신 서버 시크릿만 바꾸면 됩니다.

Sayren-Signature: t=1789804800,v1=<새 시크릿 서명>,v1=<기존 시크릿 서명>

응답과 재시도

수신 서버가 **10초 안에 2xx**로 응답해야 성공입니다(DNS 해석 시간 포함). 리다이렉트는 따라가지 않으므로 3xx도 실패이고, 등록한 주소가 최종 주소여야 합니다. 처리가 길면 먼저 2xx로 응답하고 비동기로 넘기십시오.

실패하면 직전 시도로부터 1분, 5분, 30분, 2시간 뒤에 다시 보냅니다. 최초 포함 5번 모두 실패하면 FAILED로 끝납니다.

중복과 순서

같은 이벤트가 두 번 이상 올 수 있습니다(응답 10초 초과, 수동 재전송, 전송 중 장애). 이벤트 ID가 같으므로 Sayren-Event-Id로 중복을 거르십시오.

도착 순서는 보장하지 않습니다. 이벤트마다 따로 보내고 재시도하므로, 같은 주문의 ORDER.DISPATCHED가 ORDER.PAID보다 먼저 도착할 수 있습니다. 같은 리소스의 이벤트는 sequence로 정렬하고, 이미 처리한 것보다 작은 sequence는 늦게 온 이전 이벤트로 보십시오. data의 상태는 이벤트가 생긴 시점의 값이라 최신 이벤트의 값도 현재 상태와 다를 수 있습니다. 현재 상태는 관리 API로 조회하십시오.

구독 정지와 재개

첫 실패부터 24시간 넘게 성공 없이 실패가 이어지면 구독이 SUSPENDED(suspendedAt 기록)가 되고, 재시도 대기 전송은 FAILED(WEBHOOK_SUSPENDED)로 끝납니다. 상점 플랫폼에는 전송이 중단됐습니다가 뜹니다. 한 번이라도 성공하면 실패 기록이 초기화되고, 직전 실패 뒤 3시간 넘게 실패가 없으면 다음 실패부터 새로 셉니다. 그래서 이벤트가 드문 구독은 정지되지 않을 수 있으니 FAILED를 주기적으로 확인하십시오.

정지 동안의 이벤트는 쌓이지 않습니다. 수신 서버를 고친 뒤 다음 순서로 복구합니다.

  1. [전송 재개] 또는 POST /webhooks/{webhookId}/resume으로 ACTIVE로 되돌립니다. 이미 ACTIVE면 그대로 200입니다. PUT /webhooks/{webhookId}로 수정해도 ACTIVE가 되고 연속 실패가 초기화됩니다.
  2. [실패 건 재전송] 또는 POST /webhooks/{webhookId}/deliveries/redeliver-failed로 정지 전 실패분을 다시 보냅니다.
  3. 정지 기간의 변경분은 관리 API로 맞춥니다. 예: 주문은 lastChangedFrom으로 변경분을 조회합니다.

전송 기록과 재전송

GET /webhooks/{webhookId}/deliveries는 최근 30일 기록을 최신순으로 줍니다. 이벤트 하나가 기록 하나이고 재시도는 attemptCount로 쌓입니다. attemptCount는 전송을 시작한 횟수입니다 — 보내는 도중 장애로 결과를 기록하지 못한 시도도 셉니다. 구독을 삭제하면 기록도 지워지고 대기 중 전송은 나가지 않습니다.

쿼리설명
statusPENDING(첫 시도 전) · PENDING_RETRY(재시도 대기, nextAttemptAt) · SUCCESS · FAILED(재시도 소진 또는 정지)
eventType이벤트 타입(예: ORDER.PAID)
resourceId이벤트 data의 모든 ID와 정확히 일치. 주문 ID면 그 주문의 클레임·품절 이벤트도 나옴
limit기본 50, 최대 100

각 기록의 resource는 대표 리소스 { type, id }입니다. type은 ORDER·CLAIM·PRODUCT·REVIEW·INQUIRY이고 늘어날 수 있으며, 특정할 수 없으면 null입니다. 이 id를 관리 API 조회나 API 로그 검색어로 넘깁니다. 기록에는 responseStatusCode·responseTimeMs·responseBodyExcerpt(앞 4,096자)가 있고, 실패면 errorCode·failureReason도 있습니다.

실패 원인 분류

errorCode가 큰 분류, failureReason이 세부 원인입니다.

failureReasonerrorCode확인할 것
DNSCONNECTION_FAILED도메인 철자, A·AAAA 레코드, 도메인 만료
TLSCONNECTION_FAILED인증서 만료·체인 누락, 도메인과 인증서 이름, TLS 버전
CONNECTIONCONNECTION_FAILED서버 기동, 포트·방화벽, 프록시·로드밸런서
TIMEOUTTIMEOUT먼저 2xx로 응답하고 비동기로 넘기는지, 서버 부하
HTTP_4XXNON_2XX경로·메서드, 서명 검증 로직, 인증·WAF 규칙
HTTP_5XXNON_2XX수신 핸들러 예외 로그(responseBodyExcerpt)
HTTP_OTHERNON_2XX3xx 등. 구독 주소를 최종 주소로 변경
SSRF_BLOCKEDBLOCKED_ADDRESS사설·루프백 대역으로 해석되는 DNS. 허용되지 않는 주소 참고
SUSPENDEDWEBHOOK_SUSPENDED구독 재개 후 실패분 재전송. 구독 정지와 재개 참고

값은 늘어날 수 있으니 문자열로 읽고 모르는 값은 errorCode로 판단하십시오. Store SDK의 현재 값은 WEBHOOK_FAILURE_REASONS· WEBHOOK_RESOURCE_TYPES입니다.

전송 기록 상세

GET /webhooks/{webhookId}/deliveries/{deliveryId}는 목록 항목에 실제 요청을 더해 줍니다(api.webhooks.getDelivery). 없으면 404 WEBHOOK_DELIVERY_NOT_FOUND입니다. 응답 필드는 마지막 시도의 값입니다.

필드내용
payload보낸 본문 문자열. 16KB를 넘으면 잘려 유효한 JSON이 아님
payloadTruncated잘렸으면 true
payloadMasked가림 처리로 본문이 바뀌었으면 true. 이때는 서명을 맞춰 볼 수 없음
requestHeaders보낸 헤더 [{ name, value }]. Sayren-Signature는 value가 null, Content-Length는 원래 바이트 수

서명 검증이 실패하면 payload로 직접 계산해 맞춰 보십시오.

재전송

FAILED 기록만, 원본당 한 번 재전송합니다. 새 기록은 eventId가 같고 redeliveryOf에 원본 ID가, 원본의 redeliveredAs에 새 ID가 담깁니다. 응답은 202이고 전송은 이후 진행됩니다.

요청대상
POST /webhooks/{webhookId}/deliveries/{deliveryId}/redeliver한 건. 응답은 새 전송 기록
POST /webhooks/{webhookId}/deliveries/redeliver-failed최근 72시간 FAILED 중 재전송하지 않은 건(최대 500건, 오래된 순). 응답 { "queued": 12 }
오류상황
404 WEBHOOK_DELIVERY_NOT_FOUND기록 없음
409 DELIVERY_NOT_FAILED원본이 FAILED가 아님
409 DELIVERY_ALREADY_REDELIVERED이미 재전송함
409 WEBHOOK_SUSPENDED구독 정지 중. 먼저 재개

허용되지 않는 주소

등록·수정 때 다음은 400 VALIDATION_FAILED입니다.

  • https://가 아닌 주소, 사용자 정보가 든 주소(https://user:pass@…)
  • 내부 대역으로 해석되는 주소(fieldErrors의 url 항목 code가 BLOCKED_ADDRESS). 사설망·루프백·링크 로컬(클라우드 메타데이터 포함)·CGNAT·멀티캐스트·문서용·예약 대역과 대응 IPv6이며, 여러 IP 중 하나라도 해당하면 거부합니다.

DNS에 없는 호스트도 등록되지만 전송마다 다시 검사해 내부 대역이면 BLOCKED_ADDRESS로 기록합니다. 로컬 개발은 HTTPS 터널 서비스를 쓰십시오.

테스트 전송

상점 플랫폼 [테스트 전송] 또는 POST /webhooks/{webhookId}/test({ "eventType": "ORDER.PAID" })는 샘플 data로 즉시 보냅니다. 형식·서명은 실제와 같고 이벤트 ID만 evt_test_로 시작합니다.

응답은 수신 서버의 실제 결과입니다. responseStatusCode·responseTimeMs·errorCode·failureReason(실패 원인 분류)· responseBodyExcerpt·requestHeaders·payload가 담깁니다. 테스트 전송은 재시도하지 않고 전송 기록에 남지 않으며 정지 판정에도 들어가지 않습니다.

다음 단계

이 페이지 목차