sayren Docs

Store SDK

관리(셀러) API 클라이언트 — 세션 로그인, 토큰, 상품/주문 처리

@sayren/store-sdk관리(Store) API의 zod 스키마와 fetch 클라이언트를 제공합니다.

관리 API는 2단계 토큰을 씁니다 — 유저 신원을 나타내는 userToken으로 로그인한 뒤, 특정 스토어(테넌트)의 리소스는 그 스토어로 스코프된 storeToken으로 호출합니다. 브라우저 앱은 userTokenstoreId를 실어 교환 없이 곧바로 테넌트 리소스를 호출할 수도 있습니다.

클라이언트 생성

import { createStoreClient } from "@sayren/store-sdk";

const api = createStoreClient({
  baseUrl: "https://api.sayren.app/v1",
});

요청이 어떻게 인증되는지는 아래 옵션으로 결정됩니다.

  • session — SDK 세션 매니저를 넘기면 userToken(Bearer)·401 자동 갱신·세션 정리가 자동 배선됩니다. 브라우저 앱 권장 경로입니다.
  • userToken — 유저 신원 토큰. 인증·스토어 목록 API에 사용합니다.
  • storeToken — 특정 스토어로 스코프된 토큰. 테넌트 리소스 API에 사용합니다.
  • storeIduserToken과 함께 넘기면 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으로만 호출할 수 있습니다. 시크릿 평문은 registerrotateSecret 응답에서만 한 번 나옵니다.

// 등록 (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_MODELvalueNames 길이와 축 개수 불일치, 없는 값 이름, 조합 중복, 없는 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/serververifyWebhookSignature로 합니다. 웹훅 가이드를 참고하세요.

Idempotency-Key 사용법

생성/승인/발송처럼 재시도 시 중복 부작용이 생길 수 있는 POSTIdempotency-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의 테넌트와 리소스가 불일치
  }
}

On this page