Webhook
스토어의 상태 변경을 등록한 HTTPS 주소에 JSON POST로 전달합니다. 수신 서버는 원문 서명을 검증하고, 중복 이벤트를 안전하게 처리해야 합니다.
수신 주소 등록
- 1.
HTTPS 수신 서버 준비
외부에서 접근할 수 있는 HTTPS 주소를 준비합니다. 운영 환경에서는 HTTP·localhost·사설/예약 IP·내부 도메인·URL 사용자 정보를 허용하지 않습니다.
- 2.
이벤트 및 필터 설정
스토어 대시보드 → 연동·개발자 → Webhook에서 수신 주소와 구독 이벤트를 등록합니다. 필요 시 필터, 사용자 지정 헤더와 전달 필드를 설정합니다.
- 3.
서명 키 보관 및 테스트
생성·교체 시 한 번 표시되는 signingSecret을 서버에 저장합니다. webhook.test로 서명 검증, 중복 처리, 내부 큐 저장과 2xx 응답을 확인합니다.
스토어당 활성 수신 주소는 최대 20개입니다. 서명 키를 분실했거나 유출이 의심되면 교체합니다.
전달 규칙
| 항목 | 동작 |
|---|---|
| 전달 보장 | At-least-once. 동일한 이벤트가 중복 도착할 수 있습니다. |
| 업무 이벤트 | 업무 변경과 같은 DB 트랜잭션에 기록합니다. 구독 이벤트·필터가 일치하는 활성 수신 주소마다 전송 건을 생성합니다. |
| 이벤트 ID | payload.id와 X-Quartz-Event-ID. 여러 수신 주소로 전달되어도 동일합니다. |
| Delivery ID | X-Quartz-Delivery. 수신 주소별 전송 건의 ID이며 재시도 중 유지됩니다. |
| 서명 시각 | X-Quartz-Timestamp는 Unix 초 단위입니다. 재시도마다 새로 생성되며 occurredAt과 구별합니다. |
| 수신 응답 | 5초 안에 2xx를 반환하면 전송 완료로 처리합니다. |
권장 수신 순서
이벤트 참조
주문
기존 충전 거래 (호환)
후기
쿠폰과 재고
연결 테스트
store.charge.*는 기존 충전 거래와의 호환성을 위한 이벤트입니다. 신규 주문 결제 연동에는 store.order.*를 사용합니다. 선택 옵션은 식별자와 표시명만 전달하며 구매자가 직접 입력한 원문과 원가는 포함하지 않습니다.
요청 형식
POST /webhooks/quartz HTTP/1.1
Host: merchant.example.com
Content-Type: application/json
User-Agent: Quartz-Vend-Webhook/1.0
X-Quartz-Delivery: 7f8d6b34-5ac7-4f1a-bdc2-3529228bc1af
X-Quartz-Event: store.order.fulfillment_pending
X-Quartz-Event-ID: 5a8b6cf2-6b44-4a16-a764-99d2be7c31f8
X-Quartz-Event-Version: 2026-07-18
X-Quartz-Timestamp: 1785805323
X-Quartz-Signature: v1=<64자리 HMAC-SHA256 hex>등록 URL의 경로와 쿼리를 유지해 전송합니다. 리디렉션을 수신 경로로 사용하지 않습니다. 호환 헤더 X-Quartz-Delivery-ID와 X-Quartz-Event-Type도 함께 전송됩니다.
공통 본문 필드
| 필드 | 형식 | 설명 |
|---|---|---|
id* | UUID | 업무 이벤트 ID입니다. 여러 Endpoint에 전달되어도 같은 값을 사용합니다. |
type* | string | 구독한 canonical 이벤트 이름입니다. |
version* | string | Webhook payload 계약 버전입니다. |
storeId* | 12자리 숫자 | 이벤트가 발생한 스토어 ID입니다. |
occurredAt* | RFC 3339 | 업무 이벤트가 실제로 발생한 시각입니다. |
data* | object | 이벤트마다 구조가 달라지는 데이터입니다. 아래 이벤트별 항목에서 확인합니다. |
{
"id": "5a8b6cf2-6b44-4a16-a764-99d2be7c31f8",
"type": "store.order.fulfillment_pending",
"version": "2026-07-18",
"storeId": "202603290001",
"occurredAt": "2026-07-19T01:02:03.000Z",
"data": {
"orderId": "202607190001",
"orderNumber": "#202607190001",
"status": "paid",
"buyer": {
"userId": "202607190002",
"nicknameSnapshot": "구매자"
},
"currency": "KRW",
"subtotalAmount": "24000",
"discountAmount": "0",
"totalAmount": "24000",
"lineItems": [
{
"orderLineId": "202607190001",
"productId": "202607190003",
"productNameSnapshot": "라이선스",
"quantity": 2,
"unitPrice": "12000",
"lineTotal": "24000",
"selectedOptions": []
}
]
}
}서명 검증
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const WEBHOOK_SECRET = process.env.QUARTZ_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) throw new Error("QUARTZ_WEBHOOK_SECRET is required");
app.post("/webhooks/quartz", express.raw({ type: "application/json" }), async (req, res) => {
const timestamp = req.header("X-Quartz-Timestamp") ?? "";
const deliveryId = req.header("X-Quartz-Delivery") ?? "";
const received = req.header("X-Quartz-Signature") ?? "";
const rawBody = req.body.toString("utf8");
const unixSeconds = Number(timestamp);
const validDeliveryId = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(deliveryId);
if (!validDeliveryId || !/^v1=[0-9a-f]{64}$/.test(received) ||
!Number.isFinite(unixSeconds) || Math.abs(Date.now() / 1000 - unixSeconds) > 300) {
return res.status(401).send("invalid timestamp");
}
const signedPayload = `${timestamp}.${deliveryId}.${rawBody}`;
const expected = `v1=${createHmac("sha256", WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex")}`;
const actualBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
if (actualBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(actualBuffer, expectedBuffer)) {
return res.status(401).send("invalid signature");
}
// deliveryId를 DB의 unique key로 저장한 뒤 업무 큐에 넣어요.
const event = JSON.parse(rawBody);
await enqueueQuartzEvent({ deliveryId, event });
return res.sendStatus(204);
});원문으로 검증
필터와 전달 필드
| 설정 | 동작 |
|---|---|
| filters | 이벤트의 필드·값 조건에 맞는 경우만 전달합니다. |
| allowFields | 비어 있으면 비공개 정보가 제거된 data를 전달합니다. 지정하면 해당 data.* 경로만 남깁니다. |
| anonymizeFields | 허용 필드 처리 후 지정 경로를 제거합니다. 배열 경로에는 *를 사용할 수 있습니다. 기본 제거 경로는 data.content와 data.images입니다. |
| 항상 제외 | 이메일·비밀번호·토큰·인증 헤더·쿠키·계좌번호·지급 내용·재고 원문·사용자 입력값은 설정과 관계없이 제외합니다. |
{
"filters": {
"type": "store.order.fulfilled",
"data.currency": [
"KRW",
"USD"
]
},
"allowFields": [
"data.orderId",
"data.status",
"data.totalAmount"
],
"anonymizeFields": [
"data.lineItems.*.selectedOptions"
]
}실패와 재시도
| 조건 | 처리 |
|---|---|
| 2xx | 전송 완료. 이후 업무 실패는 수신 서버의 큐에서 재처리합니다. |
| 네트워크 오류·타임아웃 | 자동 재시도 |
| 408·409·425·429·5xx | 자동 재시도. Retry-After는 최대 1시간까지 반영합니다. |
| 재시도 간격 | 최대 5회 시도. 첫 지연은 약 30초이며 지수 백오프와 jitter를 적용합니다. |
| 재시도 소진 | dead_letter 상태. 대시보드에서 확인하고 수동 재시도할 수 있습니다. |
| 410 | 수신 주소 폐기로 간주합니다. 같은 주소에서 3회 누적되면 자동 비활성화합니다. |
이미 처리한 Delivery ID에는 업무를 반복하지 않고 2xx를 반환합니다. 일시 장애에 410을 사용하지 말고 5xx 또는 429와 Retry-After로 응답합니다.