Store SDK
관리(셀러) API 클라이언트 — 세션 로그인, 토큰, 상품/주문 처리
@sayren/store-sdk는 관리(Store) API의 zod 스키마와 fetch 클라이언트를 제공합니다.
관리 API는 2단계 토큰을 씁니다 — 유저 신원을 나타내는 userToken으로 로그인한 뒤, 특정 스토어(테넌트)의
리소스는 그 스토어로 스코프된 storeToken으로 호출합니다. 브라우저 앱은 userToken에 storeId를 실어
교환 없이 곧바로 테넌트 리소스를 호출할 수도 있습니다.
클라이언트 생성
import { createStoreClient } from "@sayren/store-sdk";
const api = createStoreClient({
baseUrl: "https://api.sayren.app/v1",
});요청이 어떻게 인증되는지는 아래 옵션으로 결정됩니다.
session— SDK 세션 매니저를 넘기면userToken(Bearer)·401 자동 갱신·세션 정리가 자동 배선됩니다. 브라우저 앱 권장 경로입니다.userToken— 유저 신원 토큰. 인증·스토어 목록 API에 사용합니다.storeToken— 특정 스토어로 스코프된 토큰. 테넌트 리소스 API에 사용합니다.storeId—userToken과 함께 넘기면X-Store-Id로 실려, 교환 없이 테넌트 리소스를 호출합니다.auth.apiToken— PAT 등 비인터랙티브 토큰을 테넌트 리소스 Bearer로 싣습니다(역할 없음).
토큰 값은 문자열 또는 함수(매 요청마다 재평가)로 전달할 수 있어, 상태 저장소의 getter를 그대로 연결하면 됩니다.
브라우저 로그인 — 세션
createStoreSession이 로그인 시작부터 콜백 처리, 토큰 저장, 무음 갱신까지 담당합니다.
저장 매체는 앱이 소유하고(쿠키·스토리지 등), 저장 키는 STORE_SESSION_KEYS로 고정됩니다.
import { createStoreClient, createStoreSession } from "@sayren/store-sdk";
const baseUrl = "https://api.sayren.app/v1";
const session = createStoreSession({
baseUrl,
clientId: "{your_client_id}",
storage: {
get: (key) => cookies.get(key),
set: (key, value) => cookies.set(key, value),
remove: (key) => cookies.remove(key),
},
});
// 1) 로그인 시작 — PKCE 생성·저장 후 IdP 로그인 화면으로 이동
// 브라우저는 자동 리다이렉트, SSR 환경은 이동할 URL을 반환한다
await session.authorize({
redirectUri: "https://your-app.example.com/auth/callback",
scope: "stores:read offline_access", // offline_access = 무음 갱신용 refresh 토큰
});
// 2) 콜백에서 code·state 처리 — state 대조와 PKCE 검증은 SDK가 수행한다
await session.handleCallback(code, state);
// 3) 세션을 클라이언트에 배선하면 이후 요청 인증이 자동 처리된다
const api = createStoreClient({ baseUrl, session });
const me = await api.userinfo();
const stores = await api.auth.listMyStores();
// [{ storeId: "store_demo", storeName: "나의첫번째몰", storeCode: "mystore", role: "OWNER", status: "ACTIVE" }]로그인·가입 화면은 외부 IdP가 제공합니다. 가입 화면으로 바로 보내려면
authorize({ ..., screen: "signup" })을 씁니다 — 이후 콜백 처리는 동일합니다.
session.subscribe(cb)로 토큰 변경을 구독할 수 있습니다.
테넌트 리소스 호출
로그인한 유저가 상품·주문 등 특정 스토어의 리소스를 호출하는 방법은 두 가지입니다.
교환 없이 storeId를 실어 userToken으로 직접 호출:
const api = createStoreClient({
baseUrl,
session, // userToken 소스
storeId: "{storeId}", // X-Store-Id 헤더로 실린다
});
const products = await api.products.list({ page: 1, size: 20 });또는 userToken을 스토어로 스코프된 storeToken으로 교환:
import { exchangeStoreTokenViaOAuth } from "@sayren/store-sdk";
const store = await exchangeStoreTokenViaOAuth({
baseUrl,
clientId: "{your_client_id}",
userToken: session.accessToken,
storeId: "{storeId}",
});
// store: OAuthTokenResponse & { storeId?, role? }
const scoped = createStoreClient({ baseUrl, storeToken: store.access_token });
const products = await scoped.products.list({ page: 1, size: 20 });storeToken이 테넌트를 결정합니다
테넌트 리소스 경로에는 storeId가 없습니다. 교차 테넌트 접근 시 403 TENANT_MISMATCH, 역할 권한
부족 시 403 INSUFFICIENT_ROLE이 반환됩니다.
서버 간 연동 — client_credentials
서버 간 연동은 API 클라이언트 자격증명(Basic 인증)을 사용합니다. 클라이언트가 스토어에 바인딩되어
있어 storeToken이 곧바로 발급됩니다 — 스토어 목록 조회·교환 단계가 필요 없습니다.
// ⚠ client_secret은 서버 환경에서만 사용하세요 (브라우저 금지)
const token = await api.auth.issueStoreTokenByClientCredentials(
"demo-client",
"demo-secret-key",
"product:r order:r", // 선택 — 최소 권한으로 다운스코프
);
// token: { access_token, token_type: "Bearer", expires_in, scope } (refresh 토큰 없음)
const scoped = createStoreClient({ baseUrl, storeToken: token.access_token });
const products = await scoped.products.list({ page: 1 });개인 액세스 토큰(PAT)
CI·백엔드 스크립트처럼 사람이 개입하지 않는 경로는 PAT를 씁니다. 평문 토큰은 발급 응답에서 단 한 번만 노출되므로 안전하게 보관하세요. PAT는 테넌트에 바인딩되지만 역할이 없어, 역할이 필요한 오퍼레이션은 거부됩니다.
// 발급 — OWNER/ADMIN storeToken 필요
const created = await api.apiTokens.create({ name: "ci-bot", scopes: ["product:r", "order:r"] });
// created.token: "sy_pat_..." — 다시 조회할 수 없다
// 사용 — 테넌트 리소스 Bearer로 주입
const bot = createStoreClient({ baseUrl, auth: { apiToken: created.token } });
await bot.products.list({ page: 1 });api.apiTokens.list()로 목록을, api.apiTokens.revoke(tokenId)로 즉시 폐기를 합니다.
앱(OAuth 클라이언트) 등록과 관리
apiClients는 OWNER/ADMIN storeToken으로만 호출할 수 있습니다. 시크릿 평문은 register와 rotateSecret
응답에서만 한 번 나옵니다.
// 등록 (RFC 7591) — 응답 필드는 OAuth 표준대로 snake_case다
const app = await api.apiClients.register({
client_name: "재고 동기화",
grant_types: ["client_credentials"],
scope: "product:rw",
});
// app.client_id, app.client_secret
const apps = await api.apiClients.list({ status: "ACTIVE" });
const detail = await api.apiClients.get(app.client_id);
// detail.secretPrefix — 식별용 앞부분. 인증에는 쓸 수 없다
// 시크릿 재발급 — 기존 시크릿은 즉시 무효
const rotated = await api.apiClients.rotateSecret(app.client_id);
// rotated.clientSecret
// 비활성화 — 되돌릴 수 없다. 발급된 토큰도 즉시 무효
await api.apiClients.deactivate(app.client_id);등록 오류는 ApiError로 던져지고 code에 OAuth 오류 코드(invalid_client_metadata·invalid_redirect_uri)가
담깁니다. 역할·스코프 제약은 Store API 개요를 참고하세요.
저수준 OAuth 헬퍼
세션 매니저를 쓰지 않고 토큰 저장·state 대조를 직접 관리한다면 PKCE 헬퍼를 개별로 씁니다.
세션(createStoreSession)이 이 헬퍼들을 감싼 상위 경로입니다.
import {
buildAuthorizeUrl,
createPkcePair,
exchangeAuthorizationCode,
} from "@sayren/store-sdk";
// 1) 로그인 시작 — PKCE 페어 생성 후 authorize로 이동
const { verifier, challenge } = await createPkcePair();
sessionStorage.setItem("pkce", verifier);
location.href = buildAuthorizeUrl({
baseUrl,
clientId: "{your_client_id}",
redirectUri: "https://your-app.example.com/auth/callback",
state: crypto.randomUUID(), // 콜백에서 직접 대조한다
codeChallenge: challenge,
scope: "stores:read offline_access",
});
// 2) 콜백에서 code → userToken 교환
const tokens = await exchangeAuthorizationCode({
baseUrl,
clientId: "{your_client_id}",
redirectUri: "https://your-app.example.com/auth/callback",
code,
codeVerifier: sessionStorage.getItem("pkce")!,
});refreshUserToken({ baseUrl, clientId, refreshToken })로 무음 갱신을 직접 수행할 수 있습니다.
상품 CRUD
옵션 축(optionGroups)에 값을 넣으면 축 × 값의 옵션 조합(variants)이 만들어집니다.
이 조합이 판매·재고 단위이고, 조합마다 재고·SKU·바코드·원가·무게를 따로 둡니다.
옵션이 없는 상품도 조합 하나를 갖습니다.
// 생성 — Idempotency-Key로 중복 생성 방지 (마지막 인자)
const product = await api.products.create(
{
name: "베이직 티셔츠",
categoryId: "cat_apparel",
salePrice: 19900, // 10원 단위 (아니면 400 INVALID_PRICE_UNIT)
originalPrice: 25000,
deliveryType: "PAID",
deliveryFee: 3000,
optionGroups: [
{ name: "색상", values: [{ name: "화이트" }, { name: "블랙" }] },
{ name: "사이즈", values: [{ name: "M" }, { name: "L" }] },
],
variants: [
{ valueNames: ["화이트", "M"], stockQuantity: 50, sku: "TS-WH-M" },
{ valueNames: ["화이트", "L"], stockQuantity: 50, sku: "TS-WH-L" },
{ valueNames: ["블랙", "M"], stockQuantity: 30, sku: "TS-BK-M" },
{ valueNames: ["블랙", "L"], stockQuantity: 0, sku: "TS-BK-L", usable: false },
],
},
crypto.randomUUID(), // Idempotency-Key
);
// 옵션 없는 상품 — 축을 비우고 조합 하나에 재고를 담는다
await api.products.create({
name: "에코백",
categoryId: "cat_bag",
salePrice: 12000,
optionGroups: [],
variants: [{ valueNames: [], stockQuantity: 100 }],
});
// 조회 / 부분 수정 / 삭제
await api.products.get(product.productId);
await api.products.patch(product.productId, { salePrice: 17900 });
await api.products.remove(product.productId);
// 판매 상태 일괄 변경 (부분 성공 응답)
const result = await api.products.bulkUpdateStatus(["prod_1", "prod_2"], "SALE", crypto.randomUUID());
// result: { succeeded: string[], failed: [{ productId?, code, message }] }
// 할인 (RATE: %, AMOUNT: 원)
await api.products.setDiscount(product.productId, {
discountType: "RATE",
discountValue: 10,
startAt: "2026-07-01T00:00:00+09:00",
endAt: "2026-07-31T23:59:59+09:00",
});update(PUT)는 옵션 모델까지 요청 내용으로 교체합니다. patch로 바꿀 수 있는 필드는
name·salePrice·status·deliveryFee이고, 재고는 조합이 들고 있으므로 여기에 없습니다.
목록 응답의 stockQuantity는 판매 중인 조합 재고의 합계, hasOptions는 옵션 축 유무입니다.
옵션 조합과 재고
조합 모델은 상품과 별도로 조회하고 교체할 수 있습니다.
const model = await api.products.getOptionModel(product.productId);
// optionGroups: [{ groupId, name, sortOrder, values: [{ valueId, name, sortOrder }] }]
// variants: [{ variantId, valueIds, name: "화이트 / M", additionalPrice, stockQuantity,
// sku, barcode, costPrice, weightGram, sellerCode, usable }]
// 전체 교체 — variantId를 함께 보낸 조합은 재고와 주문 이력이 그대로 유지된다
await api.products.replaceOptionModel(product.productId, {
optionGroups: [{ name: "색상", values: [{ name: "화이트" }, { name: "블랙" }] }],
variants: [
{ variantId: model.variants[0].variantId, valueNames: ["화이트"], stockQuantity: 40 },
{ valueNames: ["블랙"], stockQuantity: 20 },
],
});
// 조합 재고 — 절대값 또는 증감
const variantId = model.variants[0].variantId;
await api.products.updateVariantStock(product.productId, variantId, { stockQuantity: 30 });
await api.products.updateVariantStock(product.productId, variantId, { adjustment: -2 });
// { variantId, stockQuantity }- 축은 최대 3개, 축마다 값은 최대 100개, 조합은 1~500개입니다.
valueNames는 축 순서대로 각 축에서 고른 값 이름입니다. 서버가 이름으로 값을 찾아 조합을 만듭니다.- 교체 요청에 없는 조합은 사라집니다. 주문에 담긴 적이 있는 조합을 빼면
409 OPTION_HAS_ORDERS로 저장이 거부되므로, 더 팔지 않을 조합은usable: false로 둡니다. 판매에서 빠지고 재고 합계에서도 제외되지만 주문 이력은 그대로 남습니다. adjustment는 현재 재고 기준 증감이라 외부 재고 시스템과 동기화할 때 경합에 안전합니다. 결과가 음수면409 INSUFFICIENT_STOCK.
| 코드 | 상황 |
|---|---|
400 INVALID_OPTION_MODEL | valueNames 길이와 축 개수 불일치, 없는 값 이름, 조합 중복, 없는 variantId |
409 DUPLICATE_SKU | 스토어 안에서 SKU·판매자 관리코드 중복 |
404 OPTION_NOT_FOUND | 없는 조합 지정 |
409 OPTION_HAS_ORDERS | 주문 이력이 있는 조합을 교체 목록에서 제외 |
409 INSUFFICIENT_STOCK | 재고 감소 결과가 음수 |
주문 처리 — 발주확인 → 발송
주문/발송/클레임은 OrderItem 단위로 처리합니다. 변경분 조회는 lastChangedFrom 기준입니다.
// 변경된 주문상품 폴링 (예: 신규 결제 완료 건). 행마다 testPayment가 있다
const changed = await api.orderItems.listChanged({
lastChangedFrom: "2026-07-08T00:00:00+09:00",
lastChangedType: "ORDER.PAID",
testPayment: "false", // 실결제 주문만. "true"는 테스트 결제만, 생략하면 전부
});
for (const item of changed.contents) {
// 1) 발주 확인 (PAID → CONFIRMED)
await api.orderItems.confirm(item.orderItemId, crypto.randomUUID());
// 2) 발송 처리 (CONFIRMED → DISPATCHED)
await api.orderItems.dispatch(
item.orderItemId,
{
deliveryMethod: "COURIER",
carrierCode: "CJGLS",
trackingNumber: "1234567890",
},
crypto.randomUUID(),
);
}
// 일괄 처리 버전
await api.orderItems.confirmBulk(["oi_1", "oi_2"], crypto.randomUUID());
await api.orderItems.dispatchBulk(
[{ orderItemId: "oi_1", deliveryMethod: "COURIER", carrierCode: "CJGLS", trackingNumber: "111" }],
crypto.randomUUID(),
);
// 클레임 처리
await api.claims.approve("claim_1", undefined, crypto.randomUUID());
await api.claims.reject("claim_2", {
reason: "USED_PRODUCT",
detail: "사용 흔적이 확인되어 반품이 불가합니다",
});매출 리포트
const report = await api.salesReport.get({
from: "2026-09-01",
to: "2026-09-19", // 양끝 포함, 최대 92일
groupBy: "PROVIDER", // DAY(기본) | PROVIDER | METHOD | PROVIDER_METHOD
// includeTest: "true", // 테스트 결제 포함. 기본은 제외
});
// report.summary: { paymentCount, paymentAmount, refundCount, refundAmount, netAmount, pgFeeAmount }
// report.rows: groupBy 기준 행. 결제·환불이 없는 날짜·PG·결제수단은 빠진다
// method는 실제 결제수단 기준(카드로 요청해도 간편결제로 결제했으면 EASY_PAY)settlement:r 스코프가 필요합니다. settlements.list·settlements.get은 폐기 예정이고 새 정산 내역이 쌓이지
않습니다. 집계 기준은 매출 리포트를 참고하세요.
방문 분석
const overview = await api.analytics.overview({ from: "2026-09-01", to: "2026-09-23" });
// overview.summary: { visitors, sessions, engagedSessions, pageViews, productViews, purchases, revenue, conversionRate, … }
// overview.funnel: SESSION → PRODUCT_VIEW → ADD_TO_CART → BEGIN_CHECKOUT → PURCHASE (세션 수)
const products = await api.analytics.products({ from: "2026-09-01", to: "2026-09-23", sort: "REVENUE", limit: 10 });
const sources = await api.analytics.sources({ from: "2026-09-01", to: "2026-09-23", groupBy: "CHANNEL" });analytics:r 스코프가 필요합니다. 수집과 집계 기준은 방문 분석을 참고하세요.
결제(PG) 설정은 paymentSettings.*로 다룹니다. 콘솔에 로그인한 OWNER의 토큰만 호출할 수 있습니다.
키 등록은 upsertTossPayments·upsertPortOne, 사용 여부와 샌드박스 모드는 updateProvider·setSandboxMode, 연결 테스트는
verify(provider, { environment }), 키 삭제는 removeCredentialSet입니다. 결제 설정과
샌드박스에서 라이브로 전환하기를 참고하세요.
웹훅 구독
const hook = await api.webhooks.create({
url: "https://example.com/hooks/sayren", // HTTPS만 허용
eventTypes: ["ORDER.PAID", "CLAIM.RETURN_REQUESTED"],
});
// hook.secret — 서명 시크릿 평문. 이후 조회에는 hook.secretPrefix만 나온다
// 시크릿 교체 — 이전 시크릿은 oldSecretExpiresAt(24시간 뒤)까지 함께 유효
const { secret, oldSecretExpiresAt } = await api.webhooks.rotateSecret(hook.webhookId);
// 수정 — SUSPENDED 구독은 수정하면 ACTIVE로 돌아간다
await api.webhooks.update(hook.webhookId, { url: hook.url, eventTypes: ["ORDER.PAID"] });
await api.webhooks.remove(hook.webhookId);
// 테스트 전송 — 등록한 주소로 샘플 이벤트를 보내고 수신 서버의 응답을 돌려준다
const test = await api.webhooks.sendTest(hook.webhookId, { eventType: "ORDER.PAID" });
// test.responseStatusCode, test.responseTimeMs, test.errorCode
// 전송 기록과 재전송
const failed = await api.webhooks.listDeliveries(hook.webhookId, { status: "FAILED" });
await api.webhooks.redeliver(hook.webhookId, failed[0].deliveryId);
// 정지된 구독 복구 — 재개한 뒤 최근 72시간의 실패 건을 일괄 재전송
await api.webhooks.resume(hook.webhookId);
const { queued } = await api.webhooks.redeliverFailed(hook.webhookId);구독은 스토어당 최대 10개이고 webhook:rw 스코프가 필요합니다. 수신 서버의 서명 검증은
@sayren/store-sdk/server의 verifyWebhookSignature로 합니다. 웹훅 가이드를 참고하세요.
Idempotency-Key 사용법
생성/승인/발송처럼 재시도 시 중복 부작용이 생길 수 있는 POST는 Idempotency-Key 헤더를 받습니다.
SDK에서는 해당 메서드의 마지막 인자 idempotencyKey로 전달합니다.
const key = crypto.randomUUID();
// 같은 키로 재호출하면 서버가 최초 응답을 재생한다 (TTL 10분)
await api.orderItems.confirm("oi_001", key);
await api.orderItems.confirm("oi_001", key); // 안전 — 중복 처리 없음지원 메서드: products.create, products.bulkUpdateStatus, categories.create,
orderItems.confirm, orderItems.confirmBulk, orderItems.cancelBySeller, orderItems.dispatch,
orderItems.dispatchBulk, claims.approve
에러 처리
Storefront SDK와 동일하게 HTTP 에러는 ApiError(status/code/message/traceId)로 던져집니다
(표준 엔벨로프의 $.error가 매핑됩니다. 성공 응답의 $.data 언랩도 SDK가 처리합니다).
import { ApiError } from "@sayren/store-sdk";
try {
await api.orderItems.confirm("oi_x");
} catch (error) {
if (error instanceof ApiError && error.code === "TENANT_MISMATCH") {
// storeToken의 테넌트와 리소스가 불일치
}
}