상품 지급
수동 지급과 API 지급은 같은 수량별 지급 상태를 사용합니다. 외부 재고 서버는 Webhook + Open API 또는 상품별 Provider URL로 연동할 수 있습니다.
지급 모델
| 항목 | 처리 규칙 |
|---|---|
| 수량 | orderId 아래 index별로 처리합니다. 장바구니의 각 상품에는 별도 orderId가 있습니다. |
| 동시 지급 | 같은 수량에 수동/API 지급 중 먼저 확정된 요청만 반영합니다. 이미 지급·환불된 수량의 후속 지급은 409입니다. |
| 교환 | 교환 시 generation이 변경됩니다. 새 차수를 조회해 지급해야 하며 이전 차수의 늦은 요청은 거부합니다. |
| 미지급 기한 | 72시간이 지나면 미지급 수량만 환불 대상으로 전환합니다. 이미 지급된 수량은 유지합니다. |
| 계좌 환불 | 환불 접수는 은행 송금이 아닙니다. 판매자가 송금 후 완료를 기록해야 합니다. |
| 연동 방식 | 호출 방향 |
|---|---|
| Webhook + Open API | Quartz → 이벤트 알림 → 외부 서버 → 주문 재조회·지급 API 호출 |
| Provider URL | Quartz → 상품별 외부 지급 서버 → 동기 응답 또는 비동기 callback |
상품별 외부 지급 주체는 하나로 정합니다. 두 방식을 중복 연결하면 외부 재고를 이중 차감할 수 있습니다. Quartz의 상태 잠금은 외부 시스템의 차감까지 되돌리지 않습니다.
Webhook + Open API
- 1.
이벤트 수신
원문 서명을 검증하고 X-Quartz-Delivery로 중복 수신을 구별합니다. 검증된 이벤트를 큐에 저장한 뒤 2xx로 응답합니다.
- 2.
주문·지급 상태 조회
GET /orders/{orderId}와 GET /orders/{orderId}/fulfillment로 상품·옵션, index, generation, 지급 기한을 조회합니다. 이벤트 본문만으로 지급하지 않습니다.
- 3.
외부 재고 예약
orderId:index:generation을 외부 재고 시스템의 고유 키로 사용합니다. 주문 수량 중 이번에 처리할 수량만 예약합니다.
- 4.
지급 반영
POST /orders/{orderId}/fulfillments에 확보한 index와 generation을 전달합니다. 미포함 수량은 대기로 남습니다. 교환은 write:order_exchange, 환불 접수는 write:order_refund, 송금 완료 기록은 write:refund_completion 권한이 별도로 필요합니다.
const response = await fetch(
"https://api.quartz-vend.com/v1/open/stores/202603290001/orders/202607190001/fulfillments",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUARTZ_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "fulfillment:202607190001:0:0"
},
body: JSON.stringify({
deliveries: [{ index: 0, generation: 0, message: "LICENSE-KEY-001" }]
})
}
);
const result = await response.json();
if (!response.ok) throw new Error(result.error?.code ?? "REQUEST_FAILED");
console.log(result.data);index는 0부터 quantity - 1 사이이며 한 요청에서 중복할 수 없습니다. deliveries.message는 구매자에게 제공되는 재고 원문이므로 요청·오류·APM 로그에서 제외합니다.
Quartz 로컬 재고를 등록하는 경우
다음 API는 Quartz가 직접 지급할 재고를 추가합니다. 외부 서버가 자체 재고를 보유하는 api_delivery 상품에는 사용하지 않습니다.
const response = await fetch(
"https://api.quartz-vend.com/v1/open/stores/202603290001/products/202607190001/inventory/append",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUARTZ_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "inventory-import-batch-001"
},
body: JSON.stringify({
items: ["LICENSE-KEY-001", "LICENSE-KEY-002"],
reason: "ERP 재고 동기화"
})
}
);
const result = await response.json();
if (!response.ok) throw new Error(result.error?.code ?? "REQUEST_FAILED");
console.log(result.data);Provider 설정
설정 필드
| 필드 | 형식 | 설명 |
|---|---|---|
fulfillmentMode* | local_inventory | api_delivery | hybrid | api_delivery는 Provider를 호출합니다. hybrid는 Quartz 로컬 재고가 없을 때 Provider를 호출합니다. |
url* | HTTPS URL | 지급 요청을 받을 공개 주소입니다. localhost·사설/예약 IP·URL 사용자 정보는 허용하지 않습니다. |
authType* | bearer | header | none | Bearer 또는 사용자 지정 헤더 인증을 권장합니다. none은 공유 credential 서명을 검증할 수 없습니다. |
authHeaderName | string | header 방식에서 credential을 받을 헤더 이름입니다. |
credential | string | Provider 인증과 요청 HMAC에 사용하는 공유 값입니다. 저장 후 원문을 조회할 수 없습니다. |
timeoutMs* | integer | 500~10,000ms. 기본값 5,000ms입니다. |
maxAttempts* | integer | 1~10회. 기본값 5회입니다. |
status* | active | disabled | active 설정만 새 지급 요청에 사용합니다. |
callbackSigningSecret은 최초 저장·재발급 시 보관합니다. 공개 API에서는 실행 모드·상태·제한 시간·재시도 횟수만 변경할 수 있습니다.
Provider 지급 요청
POST /fulfill/quartz HTTP/1.1
Host: inventory.example.com
Content-Type: application/json
Authorization: Bearer <설정한 credential>
Idempotency-Key: fulfillment:202607190001
X-Quartz-Provider-Request: 1
X-Quartz-Provider-Signature: v1=<HMAC-SHA256 hex>
{
"requestId": "5a8b6cf2-6b44-4a16-a764-99d2be7c31f8",
"idempotencyKey": "fulfillment:202607190001",
"storeId": "202603290001",
"orderId": "202607190001",
"orderItemId": "202607190001",
"productId": "202607190003",
"quantity": 2,
"selectedOptions": [],
"buyer": {
"userId": "202607190002"
}
}요청 본문 필드
| 필드 | 형식 | 설명 |
|---|---|---|
requestId* | UUID | 이번 HTTP 요청을 추적하는 ID입니다. 재시도 여부 판단에는 idempotencyKey를 사용합니다. |
idempotencyKey* | string | 같은 지급 작업의 모든 재시도에서 유지되는 값입니다. Idempotency-Key 헤더와 같습니다. |
storeId* | 12자리 숫자 | 주문이 발생한 스토어 ID입니다. |
orderId* | 12자리 숫자 | 지급할 주문 ID입니다. |
orderItemId* | 12자리 숫자 | 현재 주문 상품 행 ID이며 orderId와 같은 값입니다. |
productId* | 12자리 숫자 | Provider가 재고를 선택할 상품 ID입니다. |
quantity* | integer | 이번 지급 요청의 수량입니다. 부분 교환에서는 주문 전체 수량과 다를 수 있습니다. 성공 응답은 이 수량만큼 반환해야 합니다. |
selectedOptions* | object[] | 구매자가 선택한 상품 옵션입니다. 지급할 재고 조합을 고를 때 사용합니다. |
buyer.userId* | 12자리 숫자 | 구매자 ID입니다. 지급 데이터에는 꼭 필요한 경우에만 사용합니다. |
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const credential = process.env.QUARTZ_PROVIDER_CREDENTIAL;
if (!credential) throw new Error("QUARTZ_PROVIDER_CREDENTIAL is required");
app.post("/fulfill/quartz", express.raw({ type: "application/json" }), async (req, res) => {
const rawBody = req.body.toString("utf8");
const idempotencyKey = req.header("Idempotency-Key") ?? "";
const received = req.header("X-Quartz-Provider-Signature") ?? "";
const expected = `v1=${createHmac("sha256", credential)
.update(`${idempotencyKey}.${rawBody}`)
.digest("hex")}`;
const actualBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
if (actualBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(actualBuffer, expectedBuffer)) {
return res.status(401).json({
success: false,
error: { code: "provider.invalid_signature", message: "Invalid signature" }
});
}
const request = JSON.parse(rawBody);
// idempotencyKey를 unique key로 저장한 뒤 재고를 한 번만 차감합니다.
const result = await reserveInventoryOnce(request);
return res.status(200).json(result);
});대시보드 연결 테스트는 같은 URL에 type: connection.test와 requestId·storeId·productId를 보냅니다. 재고를 차감하지 않고 2xx로 응답합니다.
진행 중인 작업의 설정
Provider 응답
{
"success": true,
"data": {
"deliveries": [
{ "index": 0, "message": "LICENSE-KEY-001" },
{ "index": 1, "message": "LICENSE-KEY-002" }
],
"remainingStock": 125,
"providerReferenceId": "fulfillment-202607190001"
},
"requestId": "9d5dcd3b-dc7c-499a-b950-f1f7176c913d"
}{
"accepted": true,
"pending": true,
"providerReferenceId": "fulfillment-202607190001",
"requestId": "9d5dcd3b-dc7c-499a-b950-f1f7176c913d"
}{
"success": false,
"error": {
"code": "provider.out_of_stock",
"message": "재고가 부족합니다."
},
"requestId": "9d5dcd3b-dc7c-499a-b950-f1f7176c913d"
}providerReferenceId는 지급 작업마다 고유해야 합니다. 같은 주문의 부분 교환도 별도 지급 작업입니다. 같은 작업의 재시도에는 기존 값을 유지합니다.
비동기 callback
POST /v1/providers/202607190050/callback HTTP/1.1
Host: api.quartz-vend.com
Content-Type: application/json
X-Quartz-Provider-Timestamp: 2026-07-19T01:04:00.000Z
X-Quartz-Provider-Nonce: uoRWxF9nM6i8aF5d9kR2qA
X-Quartz-Provider-Signature: v1=<HMAC-SHA256 hex>
{
"providerEventId": "event-202607190001-completed",
"providerReferenceId": "fulfillment-202607190001",
"success": true,
"deliveries": [
{ "index": 0, "message": "LICENSE-KEY-001" },
{ "index": 1, "message": "LICENSE-KEY-002" }
],
"remainingStock": 125
}Callback 본문 필드
| 필드 | 형식 | 설명 |
|---|---|---|
providerEventId* | string | Provider가 생성한 callback 이벤트 ID입니다. 같은 ID를 다른 본문에 재사용하면 거부됩니다. 최대 160자입니다. |
providerReferenceId* | string | pending 응답에서 반환했던 지급 작업 ID입니다. 최대 160자입니다. |
success* | boolean | 지급 성공 여부입니다. |
deliveries* | { index, message }[] | 성공이면 이번 지급 요청의 quantity만큼 필요하고 실패이면 빈 배열이어야 합니다. |
deliveries[].index | integer | 이번 요청 안에서 0부터 quantity - 1까지 중복 없이 사용합니다. 주문의 원래 index와 다를 수 있으며, Quartz가 요청 당시 고정한 주문 수량에 연결합니다. |
deliveries[].message | string | 구매자에게 표시할 실제 지급 내용입니다. 항목당 최대 10,000자입니다. |
remainingStock | integer | null | 지급 후 Provider에 남은 재고 수량입니다. |
error.code | string | 실패일 때 필수입니다. 영문·숫자·점·밑줄·하이픈만 사용하며 최대 80자입니다. |
error.message | string | 실패 사유입니다. 최대 500자까지 보낼 수 있습니다. |
import { createHmac, randomBytes } from "node:crypto";
const callbackSecret = process.env.QUARTZ_CALLBACK_SECRET;
const providerId = "202607190050";
const body = {
providerEventId: "event-202607190001-completed",
providerReferenceId: "fulfillment-202607190001",
success: true,
deliveries: [
{ index: 0, message: "LICENSE-KEY-001" },
{ index: 1, message: "LICENSE-KEY-002" }
],
remainingStock: 125
};
const rawBody = JSON.stringify(body);
const timestamp = new Date().toISOString();
const nonce = randomBytes(24).toString("base64url");
const signature = createHmac("sha256", callbackSecret)
.update(`${timestamp}.${nonce}.${rawBody}`)
.digest("hex");
const response = await fetch(
`https://api.quartz-vend.com/v1/providers/${providerId}/callback`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Quartz-Provider-Timestamp": timestamp,
"X-Quartz-Provider-Nonce": nonce,
"X-Quartz-Provider-Signature": `v1=${signature}`
},
body: rawBody
}
);
if (!response.ok) throw new Error(await response.text());timestamp는 현재와 ±5분 이내, nonce는 16~128자의 영문·숫자·_·- 값입니다. timestamp.nonce.rawBody를 callback Secret으로 HMAC-SHA256 처리합니다. 사용한 nonce는 10분 동안 재사용할 수 없습니다.
Pending 응답 저장 전 callback 도착
재시도와 동시 처리
| 상황 | 처리 |
|---|---|
| 동일 요청 | 같은 Idempotency-Key에는 최초 지급 결과 또는 pending 상태를 반환합니다. 재고를 다시 차감하지 않습니다. |
| 부분 지급 | Open API 지급은 주문의 index별로 나누어 요청합니다. Provider 동기 응답과 callback은 이번 지급 요청의 quantity만큼 deliveries를 반환합니다. index는 이번 요청 안에서 0부터 시작하며 주문의 원래 index와 다를 수 있습니다. |
| 교환 | 부분 교환은 새 Idempotency-Key를 가진 별도 요청입니다. orderId만으로 중복 여부를 판단하지 않습니다. |
| 지급·환불 경합 | 같은 index에 수동 지급이나 환불이 먼저 확정되면 API 지급은 409입니다. 실패 응답을 받으면 외부 재고 예약 해제 여부를 자체 시스템에서 처리합니다. |
| 재시도 불가 | provider.out_of_stock은 자동 재시도하지 않습니다. |
| 일시 실패 | 최초 전송 시 보존한 maxAttempts 범위에서 재시도합니다. 원래 요청 설정을 확인할 수 없는 과거 요청은 확인 필요로 분류합니다. |
| Callback 중복 | 같은 providerEventId와 원문 body를 새 인증 헤더로 재전송하면 이미 처리된 결과는 replayed: true로 반환합니다. 같은 nonce는 409로 거부합니다. |
| Callback 충돌 | 같은 providerEventId에 다른 본문을 보내면 409입니다. |
연동 검증