웹훅
이벤트 수신, 서명 검증, 재시도와 재전송
주문·클레임·문의·리뷰·재고 이벤트가 생기면 등록한 HTTPS 주소로 POST 요청을 보냅니다.
구독 등록과 시크릿 관리는 Store API 개요와 Store SDK를
참고하세요. 셀러 콘솔 개발자 › 웹훅 화면에서도 같은 작업을 할 수 있습니다.
요청 형식
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_…",
"data": { "orderId": "ord_…", "status": "PAID", "testPayment": false }
}| 헤더 | 값 |
|---|---|
Sayren-Signature | 타임스탬프와 서명. 서명 검증 참고 |
Sayren-Event-Id | 이벤트 ID. 본문의 id와 같습니다 |
Sayren-Event-Type | 이벤트 타입. 본문의 type과 같습니다 |
Sayren-Delivery-Id | 전송 기록 ID. 전송 기록 조회·재전송에 씁니다 |
본문에는 리소스 ID와 상태만 담깁니다. 구매자 이름·연락처·주소 같은 개인정보는 넣지 않으므로,
상세 정보가 필요하면 data의 ID로 관리 API를 조회하세요. 조회 시점의 최신 상태를 받게 됩니다.
이벤트
| 타입 | 발생 시점 | data |
|---|---|---|
ORDER.PAID | 결제 완료 | orderId status testPayment |
ORDER.CONFIRMED | 발주 확인 | orderId status testPayment |
ORDER.DISPATCHED | 발송 처리 | orderId status testPayment |
ORDER.DELIVERED | 배송 완료 | orderId status testPayment |
ORDER.PURCHASE_DECIDED | 구매 확정 | orderId status testPayment |
ORDER.ADDRESS_CHANGE_REQUESTED | 구매자의 배송지 변경 요청 | orderId status testPayment |
CLAIM.CANCEL_REQUESTED | 취소 요청 | claimId orderId orderItemId claimType status testPayment |
CLAIM.RETURN_REQUESTED | 반품 요청 | claimId orderId orderItemId claimType status testPayment |
CLAIM.EXCHANGE_REQUESTED | 교환 요청 | claimId orderId orderItemId claimType status testPayment |
CLAIM.COMPLETED | 클레임 처리 완료 | claimId orderId orderItemId claimType status testPayment |
INQUIRY.CREATED | 상품 문의 등록 | inquiryId productId |
REVIEW.CREATED | 리뷰 등록 | reviewId productId rating |
PRODUCT.OUT_OF_STOCK | 판매 단위(옵션 조합)의 재고가 0이 됨 | productId variantId |
ORDER.*의status는 주문에서 취소되지 않은 주문 상품 중 가장 덜 진행된 상태입니다. 일부 상품만 발송한 주문이면ORDER.DISPATCHED의status가CONFIRMED일 수 있습니다.ORDER.CONFIRMED,ORDER.DISPATCHED,ORDER.PURCHASE_DECIDED는 주문 상품별로 처리될 때마다 발생할 수 있어 한 주문에 같은 타입이 여러 번 올 수 있습니다. 먼저 온 이벤트의status는 이전 단계일 수 있습니다.testPayment는 테스트 결제(샌드박스 모드에서 받은 결제) 주문이면true입니다. 테스트 결제 주문도 실주문과 같이 이벤트가 발생하므로, 실주문만 처리하는 연동이라면 이 값으로 거르세요. 이 필드는 2026-09-19에 추가됐습니다. 수신 측에서data를 모르는 필드를 거부하는 방식(strict)으로 검증한다면 이 필드를 허용하도록 바꿔야 합니다. Store SDK의webhookEventSchema는 필드가 없는 이벤트를false로 읽습니다.INQUIRY.CREATED는 상품 문의에만 발생합니다. 1:1 문의는 포함되지 않습니다.PRODUCT.OUT_OF_STOCK은 재고가 0으로 바뀌는 순간 한 번 발생합니다.variantId는 주문 상품의optionId와 같은 값이며, ID 형식은 고정되어 있지 않으니 접두어로 판별하지 마세요.- 셀러가 직권취소한 주문에는 웹훅이 발송되지 않습니다.
ORDER.DELIVERED, ORDER.ADDRESS_CHANGE_REQUESTED, 교환 클레임의 CLAIM.COMPLETED는 구독할 수 있지만 아직
발송되지 않습니다.
서명 검증
Sayren-Signature는 t=<unix 초>,v1=<hex> 형식입니다. v1은 구독 시크릿을 키로
{t}.{원문 본문} 문자열을 HMAC-SHA256한 값입니다. 수신 서버는 다음을 확인합니다.
t가 현재 시각과 5분 이상 차이 나면 거부합니다. 가로챈 요청을 나중에 다시 보내는 공격을 막습니다.- 직접 계산한 서명과 헤더의
v1값 중 하나가 일치하는지 상수 시간 비교로 확인합니다. 일반 문자열 비교(===)는 일치하는 앞부분 길이에 따라 응답 시간이 달라져 서명을 추측할 단서가 됩니다. - 검증은 받은 바이트 그대로의 본문으로 합니다. JSON으로 파싱한 뒤 다시 직렬화하면 키 순서·공백· 숫자 표기가 달라질 수 있고, 그러면 올바른 요청도 서명이 맞지 않습니다. 파싱은 검증을 통과한 뒤에 합니다.
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");
const header = req.get("Sayren-Signature") ?? "";
if (!verifySayrenSignature(rawBody, header, process.env.SAYREN_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(rawBody);
queue.enqueue(event); // 처리는 비동기로 넘기고
res.sendStatus(200); // 바로 응답한다
});Store SDK
@sayren/store-sdk/server의 verifyWebhookSignature가 같은 검증을 합니다. Web Crypto API로 동작하므로
Node.js, Deno, Cloudflare Workers 등에서 그대로 쓸 수 있습니다. 시크릿을 다루므로 서버 코드에서만 import하세요.
import { webhookEventSchema } 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 event = webhookEventSchema.parse(JSON.parse(rawBody));
if (event.type === "ORDER.PAID") {
// event.data.orderId
}
return new Response(null, { status: 200 });
}시크릿 교체
POST /webhooks/{webhookId}/rotate-secret으로 시크릿을 바꾸면 기존 시크릿은 24시간 동안 함께 유효합니다.
이 기간에는 Sayren-Signature에 새 시크릿과 기존 시크릿으로 만든 서명이 모두 실립니다(새 시크릿 서명이 먼저).
Sayren-Signature: t=1789804800,v1=<새 시크릿 서명>,v1=<기존 시크릿 서명>위 검증 코드는 v1 중 하나만 맞으면 통과하므로, 24시간 안에 수신 서버의 시크릿만 새 값으로 바꾸면
전송이 끊기지 않습니다. secret에 배열을 넘기면 두 시크릿을 동시에 허용할 수도 있습니다.
응답과 재시도
수신 서버가 10초 안에 2xx로 응답해야 전송 성공입니다. 10초에는 호스트 이름 해석(DNS) 시간도 포함됩니다.
그 밖의 응답은 모두 실패로 처리합니다. 리다이렉트는 따라가지 않고 3xx 응답은 실패(NON_2XX)이므로
등록한 주소가 최종 주소여야 합니다. 처리에 시간이 걸리면
먼저 2xx로 응답하고 작업은 비동기로 넘기세요.
실패하면 직전 시도로부터 1분, 5분, 30분, 2시간 뒤에 차례로 다시 보냅니다. 최초 시도를 포함해 5번 모두 실패하면 그 전송은
FAILED로 끝납니다.
중복과 순서
같은 이벤트가 두 번 이상 도착할 수 있습니다. 수신 서버가 처리를 마쳤지만 응답이 10초를 넘겼거나,
실패 건을 수동으로 재전송한 경우입니다. 재시도와 재전송은 같은 이벤트 ID를 유지하므로 Sayren-Event-Id
(본문의 id)를 저장해 두고 이미 처리한 이벤트는 건너뛰세요.
재시도 때문에 도착 순서가 발생 순서와 다를 수 있습니다. 순서가 중요하면 createdAt을 비교하거나
data의 ID로 현재 상태를 조회해 판단합니다.
구독 정지와 재개
구독 단위로 실패가 이어지는지 봅니다. 첫 실패부터 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일의 전송 기록을 최신순으로 반환합니다.
status로 거르고 limit(기본 50, 최대 100)으로 개수를 정합니다. 이벤트 하나가 기록 하나이고, 재시도는 같은 기록의
attemptCount로 쌓입니다. 구독을 삭제하면 전송 기록도 함께 삭제되고 대기 중인 전송은 발송되지 않습니다.
status | 의미 |
|---|---|
PENDING | 첫 시도 전 |
PENDING_RETRY | 실패 후 재시도 대기. nextAttemptAt에 다음 시도 시각 |
SUCCESS | 2xx 응답을 받음 |
FAILED | 재시도를 모두 소진했거나 구독이 정지되어 종료 |
실패한 기록의 errorCode로 원인을 구분합니다. responseStatusCode·responseTimeMs·responseBodyExcerpt
(응답 본문 앞 1,024자)도 함께 기록됩니다.
errorCode | 원인 |
|---|---|
TIMEOUT | 10초 안에 응답이 없음 |
CONNECTION_FAILED | 호스트 이름 해석·TCP 연결·TLS 핸드셰이크 실패 |
BLOCKED_ADDRESS | 전송할 수 없는 주소로 연결됨. 아래 참고 |
NON_2XX | 2xx가 아닌 응답. 3xx 포함 |
WEBHOOK_SUSPENDED | 구독 정지로 종료 |
재전송은 FAILED 기록에만, 원본 기록당 한 번만 할 수 있고, 새 전송 기록이 만들어집니다. 새 기록의 eventId는 원본과 같고
redeliveryOf에 원본 deliveryId가 담깁니다. 원본 기록의 redeliveredAs에는 새 기록의 deliveryId가 담기며,
재전송하지 않은 기록은 null입니다. 두 요청 모두 202 Accepted로 응답하며 전송은 이후 진행됩니다.
| 요청 | 대상 |
|---|---|
POST /webhooks/{webhookId}/deliveries/{deliveryId}/redeliver | 전송 기록 한 건. 응답은 새 전송 기록 |
POST /webhooks/{webhookId}/deliveries/redeliver-failed | 최근 72시간의 FAILED 중 아직 재전송하지 않은 건 전체(최대 500건, 오래된 순). 응답의 queued는 실제로 새로 적재된 건수. 예: { "queued": 12 } |
전송 기록이 없으면 404 WEBHOOK_DELIVERY_NOT_FOUND, 원본이 FAILED가 아니면 409 DELIVERY_NOT_FAILED,
이미 재전송한 원본이면 409 DELIVERY_ALREADY_REDELIVERED, 구독이 SUSPENDED면 409 WEBHOOK_SUSPENDED가 반환됩니다.
정지된 구독은 먼저 재개하세요.
허용되지 않는 주소
구독 주소는 인터넷에서 접근할 수 있는 https:// 주소여야 합니다. 등록(POST /webhooks)과 수정
(PUT /webhooks/{webhookId}) 때 다음 주소는 400 VALIDATION_FAILED로 거부됩니다.
https://가 아닌 주소- 사용자 정보가 들어간 주소(
https://user:pass@example.com/…) - 호스트가 내부 대역으로 해석되는 주소. 이때
fieldErrors의url항목code가BLOCKED_ADDRESS입니다
내부 대역은 사설망, 루프백, 링크 로컬(클라우드 메타데이터 주소 포함), CGNAT, 멀티캐스트, 문서용·예약 대역과 이에 대응하는 IPv6 대역입니다. 호스트가 여러 IP로 해석되면 하나라도 내부 대역일 때 거부합니다.
아직 DNS에 등록되지 않은 호스트는 등록할 수 있습니다. 전송할 때마다 주소를 다시 검사하므로, 그때 내부 대역으로
해석되면 전송하지 않고 BLOCKED_ADDRESS로 기록합니다. 로컬 개발 중에는 공개 HTTPS 주소를 주는 터널 서비스로
수신 서버를 노출하세요.
테스트 전송
POST /webhooks/{webhookId}/test에 이벤트 타입을 지정하면 샘플 data를 담은 요청을 등록한 주소로 즉시
보냅니다. 형식과 서명은 실제 이벤트와 같고 이벤트 ID만 evt_test_로 시작합니다.
curl https://api.sayren.app/v1/webhooks/{webhookId}/test \
-H "Authorization: Bearer {storeToken}" \
-d '{ "eventType": "ORDER.PAID" }'수신 서버의 실제 응답이 결과로 돌아옵니다(responseStatusCode, responseTimeMs, errorCode).
테스트 전송은 재시도하지 않고 전송 기록에 남지 않으며, 구독 정지 판정에도 들어가지 않습니다.