sayren Docs

웹훅

이벤트 수신, 서명 검증, 재시도와 재전송

주문·클레임·문의·리뷰·재고 이벤트가 생기면 등록한 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.DISPATCHEDstatusCONFIRMED일 수 있습니다.
  • 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-Signaturet=<unix 초>,v1=<hex> 형식입니다. v1은 구독 시크릿을 키로 {t}.{원문 본문} 문자열을 HMAC-SHA256한 값입니다. 수신 서버는 다음을 확인합니다.

  1. t가 현재 시각과 5분 이상 차이 나면 거부합니다. 가로챈 요청을 나중에 다시 보내는 공격을 막습니다.
  2. 직접 계산한 서명과 헤더의 v1 값 중 하나가 일치하는지 상수 시간 비교로 확인합니다. 일반 문자열 비교(===)는 일치하는 앞부분 길이에 따라 응답 시간이 달라져 서명을 추측할 단서가 됩니다.
  3. 검증은 받은 바이트 그대로의 본문으로 합니다. 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/serververifyWebhookSignature가 같은 검증을 합니다. 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를 주기적으로 확인하세요.

정지 동안 발생한 이벤트는 전송 대상에 쌓이지 않습니다. 수신 서버를 고친 뒤 다음 순서로 복구하세요.

  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일의 전송 기록을 최신순으로 반환합니다. status로 거르고 limit(기본 50, 최대 100)으로 개수를 정합니다. 이벤트 하나가 기록 하나이고, 재시도는 같은 기록의 attemptCount로 쌓입니다. 구독을 삭제하면 전송 기록도 함께 삭제되고 대기 중인 전송은 발송되지 않습니다.

status의미
PENDING첫 시도 전
PENDING_RETRY실패 후 재시도 대기. nextAttemptAt에 다음 시도 시각
SUCCESS2xx 응답을 받음
FAILED재시도를 모두 소진했거나 구독이 정지되어 종료

실패한 기록의 errorCode로 원인을 구분합니다. responseStatusCode·responseTimeMs·responseBodyExcerpt (응답 본문 앞 1,024자)도 함께 기록됩니다.

errorCode원인
TIMEOUT10초 안에 응답이 없음
CONNECTION_FAILED호스트 이름 해석·TCP 연결·TLS 핸드셰이크 실패
BLOCKED_ADDRESS전송할 수 없는 주소로 연결됨. 아래 참고
NON_2XX2xx가 아닌 응답. 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, 구독이 SUSPENDED409 WEBHOOK_SUSPENDED가 반환됩니다. 정지된 구독은 먼저 재개하세요.

허용되지 않는 주소

구독 주소는 인터넷에서 접근할 수 있는 https:// 주소여야 합니다. 등록(POST /webhooks)과 수정 (PUT /webhooks/{webhookId}) 때 다음 주소는 400 VALIDATION_FAILED로 거부됩니다.

  • https://가 아닌 주소
  • 사용자 정보가 들어간 주소(https://user:pass@example.com/…)
  • 호스트가 내부 대역으로 해석되는 주소. 이때 fieldErrorsurl 항목 codeBLOCKED_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). 테스트 전송은 재시도하지 않고 전송 기록에 남지 않으며, 구독 정지 판정에도 들어가지 않습니다.

On this page