웹훅
이벤트 수신, 서명 검증, 전송 실패와 재시도·재전송, 구독 정지와 재개
주문·클레임·문의·리뷰·재고 이벤트가 생기면 등록한 HTTPS 주소로 POST 요청을 보냅니다. 구독은 상점당 10개까지입니다
(넘으면 409 WEBHOOK_LIMIT_EXCEEDED).
상점 플랫폼에서 등록하기
상점 플랫폼에서 개발자 › 이벤트 › 웹훅으로 이동합니다(소유자·관리자).
- **[엔드포인트 추가]**를 누릅니다.
- 엔드포인트 URL(
https://)과 받을 이벤트를 고르고 **[추가]**를 누릅니다. - 서명 시크릿을 복사해 수신 서버에 넣습니다. 전체 값은 이때만 보입니다.
- 엔드포인트 상세의 **[테스트 전송]**으로 수신을 확인합니다.
상세에서 편집·삭제, 시크릿 재발급, 전송 기록과 재전송을 합니다. 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-sdk0.20.0). 그 전에 만들어진 이벤트는 재시도·수동 재전송에서fulfillment.mode나 옛 유형 값(DOWNLOAD·CODE·SERVICE·PICKUP)을 실을 수 있습니다. SDK는 옛 유형을OTHER로 읽고 모르는 필드는 버립니다. 자세한 흐름은 비실물 상품 판매하기에 있습니다. - 배송완료·구매확정은 주문 처리하기를 참고하십시오.
서명 검증
Sayren-Signature는 t=<unix 초>,v1=<hex>입니다. v1은 구독 시크릿을 키로 {t}.{원문 본문}을 HMAC-SHA256한 값입니다.
t가 현재 시각과 5분 넘게 차이 나면 거부합니다(재전송 공격 방지).- 직접 계산한 서명과
v1값 중 하나가 일치하는지 상수 시간 비교로 확인합니다.===는 응답 시간으로 서명을 추측할 단서가 됩니다. - 받은 바이트 그대로의 본문으로 검증합니다. 파싱 뒤 다시 직렬화하면 서명이 맞지 않습니다. 파싱은 검증 뒤에 합니다.
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를 주기적으로 확인하십시오.
정지 동안의 이벤트는 쌓이지 않습니다. 수신 서버를 고친 뒤 다음 순서로 복구합니다.
- [전송 재개] 또는
POST /webhooks/{webhookId}/resume으로ACTIVE로 되돌립니다. 이미ACTIVE면 그대로 200입니다.PUT /webhooks/{webhookId}로 수정해도ACTIVE가 되고 연속 실패가 초기화됩니다. - [실패 건 재전송] 또는
POST /webhooks/{webhookId}/deliveries/redeliver-failed로 정지 전 실패분을 다시 보냅니다. - 정지 기간의 변경분은 관리 API로 맞춥니다. 예: 주문은
lastChangedFrom으로 변경분을 조회합니다.
전송 기록과 재전송
GET /webhooks/{webhookId}/deliveries는 최근 30일 기록을 최신순으로 줍니다. 이벤트 하나가 기록 하나이고 재시도는
attemptCount로 쌓입니다. attemptCount는 전송을 시작한 횟수입니다 — 보내는 도중 장애로 결과를 기록하지 못한 시도도 셉니다. 구독을 삭제하면 기록도 지워지고 대기 중 전송은 나가지 않습니다.
| 쿼리 | 설명 |
|---|---|
status | PENDING(첫 시도 전) · 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이 세부 원인입니다.
failureReason | errorCode | 확인할 것 |
|---|---|---|
DNS | CONNECTION_FAILED | 도메인 철자, A·AAAA 레코드, 도메인 만료 |
TLS | CONNECTION_FAILED | 인증서 만료·체인 누락, 도메인과 인증서 이름, TLS 버전 |
CONNECTION | CONNECTION_FAILED | 서버 기동, 포트·방화벽, 프록시·로드밸런서 |
TIMEOUT | TIMEOUT | 먼저 2xx로 응답하고 비동기로 넘기는지, 서버 부하 |
HTTP_4XX | NON_2XX | 경로·메서드, 서명 검증 로직, 인증·WAF 규칙 |
HTTP_5XX | NON_2XX | 수신 핸들러 예외 로그(responseBodyExcerpt) |
HTTP_OTHER | NON_2XX | 3xx 등. 구독 주소를 최종 주소로 변경 |
SSRF_BLOCKED | BLOCKED_ADDRESS | 사설·루프백 대역으로 해석되는 DNS. 허용되지 않는 주소 참고 |
SUSPENDED | WEBHOOK_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로 직접 계산해 맞춰 보십시오.
- 키 이름에
authorization·token·secret·password·key·signature·credential·cookie가 들어 있으면(부분 일치, 대소문자 무시) 값이***가 됩니다.apiKey·access_token·keyword도 해당합니다. 객체·배열 값은 통째로 가리고 숫자·불리언·null은 둡니다. - 응답 본문은 헤더 줄(
Authorization: …),Bearer <토큰>,access_token=…형태도 가립니다. - 응답 본문은 가린 뒤 4,096자로 자릅니다. 64KB를 넘는 응답은 앞 64KB만 읽습니다.
재전송
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가 담깁니다. 테스트 전송은 재시도하지 않고 전송 기록에 남지 않으며 정지 판정에도
들어가지 않습니다.