Store SDK
관리(셀러) API 클라이언트 — 세션 로그인, 토큰, 상품/주문 처리
@sayren/store-sdk는 관리 API의 zod 스키마와 fetch 클라이언트입니다. 성공 응답의 data 언랩과 스키마 검증을 SDK가 합니다.
준비
npm install @sayren/store-sdk- 계정 토큰: Sayren 계정 › 액세스 토큰에서 만듭니다(인증과 계정 토큰).
- 상점 id: 부를 상점의 id입니다. 요청마다
X-Store-Id로 실립니다.
클라이언트 생성
import { createStoreClient } from "@sayren/store-sdk";
const api = createStoreClient({
baseUrl: "https://api.sayren.io/v1",
accessToken: process.env.SAYREN_ACCESS_TOKEN,
storeId: "store_abc123",
});토큰과 상점 id는 문자열이나 함수(요청마다 다시 읽음)로 줍니다.
| 옵션 | 용도 |
|---|---|
accessToken | 계정 토큰(액세스 토큰 또는 로그인 토큰). 모든 요청에 Authorization: Bearer로 실립니다 |
storeId | 상점 리소스 요청에 X-Store-Id로 실립니다. 계정 API(auth.me·auth.listMyStores)에는 싣지 않습니다 |
session | 브라우저 로그인 세션. 토큰·401 자동 갱신·세션 정리를 배선합니다 |
refresh·onUnauthorized | 401에서 갱신을 시도하는 함수, 복구할 수 없는 401에서 부르는 함수. session의 기본값보다 우선합니다 |
storeId를 빼고 상점 리소스를 부르면 400 STORE_ID_REQUIRED입니다. 옛 옵션 storeToken·userToken·auth.apiToken과
apiTokens·apiClients·auth.issueStoreTokenByClientCredentials·auth.revoke·exchangeStoreTokenViaOAuth는 0.27.0에서 없어졌습니다.
브라우저 로그인 — 세션
createStoreSession이 Sayren 계정 로그인 시작, 콜백 처리, 토큰 저장, 무음 갱신, 로그아웃을 맡습니다. 저장 매체는 앱이 정하고 키는
STORE_SESSION_KEYS입니다. authServer에는 Sayren 계정의 OAuth 엔드포인트와 관리 API 리소스(resource)를 줍니다.
import { createStoreClient, createStoreSession } from "@sayren/store-sdk";
const baseUrl = "https://api.sayren.io/v1";
const accounts = "https://accounts.sayren.io/api/auth/oauth2";
const session = createStoreSession({
baseUrl,
authServer: {
authorizeUrl: `${accounts}/authorize`,
tokenUrl: `${accounts}/token`,
revokeUrl: `${accounts}/revoke`,
resource: "https://api.sayren.io",
},
clientId: "{your_client_id}",
storage: { get: (k) => cookies.get(k), set: (k, v) => cookies.set(k, v), remove: (k) => cookies.remove(k) },
});
// 1) 로그인 시작 — PKCE를 만들고 Sayren 계정 로그인으로 이동. SSR에서는 이동할 URL을 돌려준다
await session.authorize({
redirectUri: "https://your-app.example.com/auth/callback",
scope: "openid stores:read product:r offline_access", // offline_access = 무음 갱신용 refresh 토큰
// prompt: "create" — 가입 화면으로 바로 보낸다
});
// 2) 콜백 — state 대조와 PKCE 검증은 SDK가 한다
await session.handleCallback(code, state);
// 3) 세션을 배선하면 이후 인증은 자동이다
const api = createStoreClient({ baseUrl, session, storeId: "{storeId}" });
const stores = await api.auth.listMyStores();
// [{ storeId, storeName, storeCode, role: "OWNER", status: "ACTIVE" }]
// 4) 로그아웃 — refresh 토큰(갱신 체인 전체)과 access 토큰을 서버에서 폐기한 뒤 저장한 세션을 지운다
await session.logout();prompt는login(다시 로그인)·create(가입)·consent(권한 허용 다시 받기) 중 하나입니다.session.clear()는 저장한 값만 지우고 서버의 토큰은 그대로 둡니다. 로그아웃에는session.logout()을 쓰십시오.- 사용자가 권한을 허용할 때 고른 상점 범위 밖의 상점을 부르면
403 STORE_NOT_GRANTED이고,listMyStores()에는 범위 안의 상점만 나옵니다.
저수준 OAuth 헬퍼
세션 없이 토큰 저장·state 대조를 직접 하려면 createPkcePair() → buildAuthorizeUrl({ authorizeUrl, clientId, redirectUri, state, codeChallenge, scope, resource })
→ 콜백에서 exchangeAuthorizationCode({ tokenUrl, clientId, redirectUri, code, codeVerifier, resource }) 순서로 부릅니다.
갱신은 refreshUserToken({ tokenUrl, clientId, refreshToken, resource })입니다. createStoreSession이 이 헬퍼들을 감싼 상위 경로입니다.
서버 연동
스크립트·CI·서버는 액세스 토큰을 씁니다. 토큰은 서버 환경변수에만 두고 브라우저 번들에 넣지 마십시오. 토큰 발급과 폐기는 Sayren 계정 › 액세스 토큰에서 합니다. API로는 발급할 수 없습니다.
구매자 위임(자체 회원 연동)은 @sayren/store-sdk/server의 delegateMember입니다(자체 회원 연동).
상품 CRUD
옵션 축(optionGroups) × 값이 옵션 조합(variants)이 됩니다. 조합이 판매·재고 단위이고 옵션 없는 상품도 조합 하나를 갖습니다.
// 생성 — 마지막 인자는 Idempotency-Key
const product = await api.products.create(
{
name: "베이직 티셔츠",
categoryId: "cat_apparel",
salePrice: 19900, // 10원 단위 (아니면 400 INVALID_PRICE_UNIT)
originalPrice: 25000,
// 유형과 유형별 속성 — 생략하면 배송 상품(상점 기본 배송비·기본 주소)
fulfillment: { type: "SHIPPING", shipping: { deliveryType: "PAID", deliveryFee: 3000 } },
refundPolicy: { returnDeliveryFee: 3000, exchangeDeliveryFee: 6000 }, // 생략하면 상점 기본
optionGroups: [
{ name: "색상", values: [{ name: "화이트" }, { name: "블랙" }] },
{ name: "사이즈", values: [{ name: "M" }, { name: "L" }] },
],
variants: [
{ valueNames: ["화이트", "M"], stockQuantity: 50, sku: "TS-WH-M" },
{ valueNames: ["블랙", "L"], stockQuantity: 0, sku: "TS-BK-L", usable: false },
// … 축 × 값 조합마다 한 줄
],
},
crypto.randomUUID(),
);
// 옵션 없는 상품은 optionGroups: [], variants: [{ valueNames: [], stockQuantity: 100 }]
// get·patch·remove(productId), update(productId, body)
await api.products.patch(product.productId, { salePrice: 17900 });
// 판매 상태 일괄 변경 — { succeeded, failed: [{ productId?, code, message }] }
await api.products.bulkUpdateStatus(["prod_1", "prod_2"], "SALE", crypto.randomUUID());
// 할인 (RATE: %, AMOUNT: 원). startAt·endAt은 선택
await api.products.setDiscount(product.productId, { discountType: "RATE", discountValue: 10, endAt: "2026-07-31T23:59:59+09:00" });update(PUT)는 옵션 모델까지 요청 내용으로 바꿉니다.fulfillment를 생략하면 이행은 그대로입니다.patch는name·salePrice·status·fulfillment·refundPolicy만 바꿉니다. 재고는 조합에 있어 여기 없습니다.- 수정(
update·patch)에서fulfillment를 주면 유형 안의 필드를 모두 담습니다. 조회한product.fulfillment를 고쳐 보내면 됩니다. 요청은 정한 필드만 받습니다. 옛 평면 필드(deliveryFee·fulfillmentType·returnFee등)는400입니다. - 응답의
fulfillment는 판별 유니온입니다.fulfillment.type으로 좁힌 뒤fulfillment.shipping등을 읽고, 모르는 유형은{ type: "OTHER", rawType }입니다.fulfillmentTypeLocked가true면 판매 이력이 있어 유형을 바꿀 수 없습니다.
const current = await api.products.get(product.productId);
if (current.fulfillment.type === "SHIPPING") {
await api.products.patch(product.productId, {
fulfillment: { type: "SHIPPING", shipping: { ...current.fulfillment.shipping, deliveryFee: 2500 } },
});
}- 목록의
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 },
],
});
// 조합 재고 — 절대값 또는 증감. 응답 { variantId, stockQuantity }
const variantId = model.variants[0].variantId;
await api.products.updateVariantStock(product.productId, variantId, { stockQuantity: 30 });
await api.products.updateVariantStock(product.productId, variantId, { adjustment: -2 });- 축 최대 3개, 축마다 값 100개, 조합 1~500개입니다.
valueNames는 축 순서대로 고른 값 이름입니다. - 교체에서
stockQuantity를 빼면 기존 재고를 유지합니다(새 조합은 0). - 교체 요청에 없는 조합은 사라집니다. 주문된 적 있는 조합은 빼지 말고
usable: false로 두십시오. 판매와 재고 합계에서 빠지고 이력은 남습니다. adjustment는 현재 재고 기준 증감이라 외부 재고와 동기화할 때 경합에 안전합니다.
| 코드 | 상황 |
|---|---|
400 INVALID_OPTION_MODEL | valueNames 길이 불일치, 없는 값 이름, 조합 중복, 없는 variantId |
409 DUPLICATE_SKU | 상점 안에서 SKU·판매자 관리코드 중복 |
404 OPTION_NOT_FOUND | 없는 조합 지정 |
409 OPTION_HAS_ORDERS | 주문 이력이 있는 조합을 교체 목록에서 뺌 |
409 INSUFFICIENT_STOCK | 재고 감소 결과가 음수 |
배송비 규칙과 출고지·반품지
배송비는 배송 묶음(같은 출고지 + 같은 배송 정책)마다 한 번 붙습니다. 규칙은 배송비와 출고지·반품지에 있습니다.
// 상점 배송비 규칙 — 준 항목만 바뀐다. 새 주문서부터 적용
await api.store.update({
shippingPolicy: { baseDeliveryFee: 3500, freeThreshold: 50000, jejuSurcharge: 3000 },
});
// (await api.store.get()).shippingPolicy: { baseDeliveryFee, freeThreshold, jejuSurcharge, isolatedSurcharge }
// 출고지·반품지 주소록 — 유형별 10개, 유형마다 기본 1개. create·update·setDefault는 본문을 돌려주지 않는다
await api.sellerAddresses.create({ type: "SHIPPING", name: "본사 창고", contactName: "김담당", phone: "01012345678", zipCode: "06236", address1: "서울 강남구 테헤란로 123" });
const shipping = await api.sellerAddresses.list("SHIPPING");
await api.sellerAddresses.setDefault(shipping[1].addressId);
// 상품에 지정 — 생략하면 그 시점의 기본 주소, null이면 비워 두고 상점 기본을 따른다
await api.products.create({
/* … */
fulfillment: { type: "SHIPPING", shipping: { shippingAddressId: shipping[0].addressId } },
});상품 등록에서 fulfillment.shipping.deliveryFee를 빼면 그 시점의 기본 배송비가 숫자로 저장됩니다. 기본 주소(409 DEFAULT_ADDRESS_UNDELETABLE)와
상품이 쓰는 주소(409 ADDRESS_IN_USE)는 삭제할 수 없습니다.
주문 처리 — 발주확인 → 발송
주문·발송·클레임은 OrderItem 단위입니다. 변경분은 lastChangedFrom부터 최대 24시간씩 조회합니다.
const changed = await api.orderItems.listChanged({
lastChangedFrom: "2026-07-08T00:00:00+09:00",
lastChangedType: "ORDER.PAID",
testPayment: "false", // "false" 실결제만, "true" 테스트 결제만, 생략 전부
});
for (const item of changed.contents) {
await api.orderItems.confirm(item.orderItemId, crypto.randomUUID()); // PAID → CONFIRMED
// CONFIRMED → DISPATCHED. dispatchedAt은 선택(시간대 있는 ISO 8601, 생략하면 현재 시각)
await api.orderItems.dispatch(item.orderItemId, { deliveryMethod: "COURIER", carrierCode: "CJGLS", trackingNumber: "1234567890" }, crypto.randomUUID());
}
// 일괄 처리 — confirmBulk(ids, key), dispatchBulk([{ orderItemId, ...발송 정보 }], key)
// 클레임 — approve(claimId, deductAmount?, idempotencyKey?)
await api.claims.approve("claim_1", undefined, crypto.randomUUID());
await api.claims.reject("claim_2", { reason: "USED_PRODUCT", detail: "사용 흔적이 확인되어 반품이 불가합니다" });택배사 코드는 api.carriers.list()로 봅니다. 배송완료·구매확정·클레임 흐름은 주문 처리에 있습니다.
매출 리포트
// 양끝 포함 최대 92일. groupBy: DAY(기본)·PROVIDER·METHOD·PROVIDER_METHOD. includeTest: "true"면 테스트 결제 포함
const report = await api.salesReport.get({ from: "2026-09-01", to: "2026-09-19", groupBy: "PROVIDER" });
// summary: { paymentCount, paymentAmount, refundCount, refundAmount, netAmount, pgFeeAmount }
// rows: 거래 없는 행은 빠진다. method는 실제 결제수단(EASY_PAY 구분)settlement:r이 필요하고 settlements.*는 폐기 예정입니다(매출 리포트).
방문 분석
const range = { from: "2026-09-01", to: "2026-09-23" };
const overview = await api.analytics.overview(range); // summary: { visitors, sessions, pageViews, purchases, revenue, … }, funnel
await api.analytics.products({ ...range, sort: "REVENUE", limit: 10 }); // sources({ groupBy: "CHANNEL" })·devices·pages·search·checkout·realtime()analytics:r이 필요합니다(방문 분석).
결제(PG) 설정은 paymentSettings.*이고 상점 플랫폼에 로그인한 OWNER 토큰만 호출합니다. 키 등록 upsertTossPayments·upsertPortOne,
사용 여부·샌드박스 updateProvider·setSandboxMode, 연결 테스트 verify(provider, { environment }), 키 삭제 removeCredentialSet입니다.
결제 설정과 라이브 전환을 보십시오.
구매자 관리
const page = await api.customers.list({ q: "5678", marketing: "agreed", sort: "total_paid_desc" });
// contents: [{ customerId, name, email, maskedPhone, joinedAt, point, marketingAgreed, orderCount, totalPaid, lastOrderedAt }]
await api.customers.get(customerId); // 휴대폰 전체·배송지·통계
await api.customers.orders(customerId, { page: 1, size: 10 });
await api.customers.consents(customerId); // 동의 이력
await api.customers.withdraw(customerId, { reason: "BUYER_REQUEST", memo: "고객센터 요청" });q는 이름·이메일, 숫자 4자리면 휴대폰 뒤 4자리로 찾습니다.totalPaid는 취소와 완료된 반품을 뺀 금액입니다.- 조회는
customer:r, 탈퇴 처리는customer:rw+ 상점 플랫폼에 로그인한 OWNER·ADMIN입니다. 되돌릴 수 없고, 진행 중인 거래가 있으면409 WITHDRAWAL_BLOCKED입니다.
구매자 로그인 설정
await api.loginSettings.get(); // { passwordLoginEnabled, storefrontOrigins, providers: [{ provider, connected, enabled, callbackUrl, … }], … }
await api.loginSettings.saveProvider("kakao", { clientId: "REST API 키", clientSecret: "Client Secret" });
await api.loginSettings.setProviderEnabled("kakao", true);
await api.loginSettings.update({ passwordLoginEnabled: false });상점 플랫폼에 로그인한 OWNER만 호출합니다. 키가 틀리면 저장하지 않습니다(422 LOGIN_PROVIDER_VERIFICATION_FAILED).
공급자를 켜려면 스토어프론트 도메인(storefrontOrigins)이 1개 이상 필요합니다(409 STOREFRONT_ORIGIN_REQUIRED).
공급자 개발자 콘솔에는 callbackUrl을 리디렉트 URI로 등록합니다. 구매자 쪽 흐름은 소셜 로그인에 있습니다.
웹훅 구독
const hook = await api.webhooks.create({
url: "https://example.com/hooks/sayren", // HTTPS만
eventTypes: ["ORDER.PAID", "CLAIM.RETURN_REQUESTED"],
}); // hook.secret — 평문은 이 응답에만. 이후에는 secretPrefix
// 시크릿 교체 — 이전 시크릿은 oldSecretExpiresAt(24시간)까지 함께 유효
const { secret, oldSecretExpiresAt } = await api.webhooks.rotateSecret(hook.webhookId);
await api.webhooks.update(hook.webhookId, { url: hook.url, eventTypes: ["ORDER.PAID"] }); // SUSPENDED면 ACTIVE로
await api.webhooks.remove(hook.webhookId);
// 테스트 전송 — { responseStatusCode, responseTimeMs, errorCode, … }
await api.webhooks.sendTest(hook.webhookId, { eventType: "ORDER.PAID" });
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는 마지막 인자로 idempotencyKey를 받습니다. 같은 키로 10분 안에 다시 부르면 처리하지 않고 첫 응답을 돌려줍니다.
대상은 products.create·bulkUpdateStatus, categories.create, orderItems.confirm·confirmBulk·cancelBySeller·dispatch·dispatchBulk·markDelivered·markDeliveredBulk, claims.approve입니다.
에러 처리
HTTP 에러는 ApiError로 던져집니다. 필드는 status·code·message·traceId·details·docs이고 뜻은
표준 응답 구조와 같습니다. 분기는 code로 합니다.
import { ApiError, ApprovalPendingError } from "@sayren/store-sdk";
try {
await api.orderItems.confirm("oi_x");
} catch (error) {
if (error instanceof ApprovalPendingError) {
// AI 에이전트 토큰의 변경이 승인 대기로 보류됨 — error.approvalId, error.status, error.approval.approvalUrl
} else if (error instanceof ApiError && error.code === "STORE_NOT_GRANTED") {
// 토큰의 상점 범위 밖의 상점을 부름
}
}ApprovalPendingError는 ApiError가 아닙니다. code는 APPROVAL_REQUIRED(202, 보류) 또는 APPROVAL_ALREADY_DECIDED(같은 멱등키의 승인이 이미 끝남)이고,
status는 승인 요청 상태(PENDING·EXECUTING·SUCCEEDED·FAILED·UNKNOWN·REJECTED·EXPIRED)입니다. 결과는 approvalRequests.get(approvalId)로 봅니다.
흐름은 AI 작업 승인과 감사 로그에 있습니다.