@01.software/sdk
v0.51.0
Published
01.software SDK
Readme
@01.software/sdk
Official TypeScript SDK for the 01.software platform.
Installation
npm install @01.software/sdk
# or
pnpm add @01.software/sdkFeatures
- Full TypeScript type inference
- Browser and server environment support
- React Query integration (both Client and ServerClient)
- Mutation hooks (useCreate, useUpdate, useRemove) with automatic cache invalidation
- Customer auth hooks (useCustomerMe, useCustomerLogin, etc.) with cache management
- Automatic retry with exponential backoff (non-retryable: 400, 401, 403, 404, 409, 422)
- Webhook handling with HMAC-SHA256 signature verification
- Sub-path imports (
./server,./webhook,./realtime,./storefront-cache,./ui/*) for tree-shaking - Type-safe read-only
collections.from()for Client (compile-time write prevention)
Sub-path Imports
// Main entry - browser client, query builder, commerce helpers, utilities
import { createClient } from '@01.software/sdk'
// Server-only entry - keep Secret Key code out of browser-facing imports
import { createServerClient, isCommerceSDKError } from '@01.software/sdk/server'
// Webhook only - webhook handlers
import {
handleSignedWebhook,
createTypedWebhookHandler,
} from '@01.software/sdk/webhook'
// Realtime only
import { RealtimeConnection } from '@01.software/sdk/realtime'
// Storefront cache resource names for SSG/ISR adapters
import { storefrontCacheResources } from '@01.software/sdk/storefront-cache'
// Embedded app browser handshake
import { receiveEmbeddedAdminSession } from '@01.software/sdk/embedded-admin'
// Embedded app backend verification
import { verifyEmbeddedAdminSession } from '@01.software/sdk/embedded-admin/server'
// Components - sub-path imports per domain
import { Analytics } from '@01.software/sdk/analytics/react'
import { RichTextContent } from '@01.software/sdk/ui/rich-text'
import { Image } from '@01.software/sdk/ui/image'
import { FormRenderer } from '@01.software/sdk/ui/form'
import { CodeBlock } from '@01.software/sdk/ui/code-block'
import { CanvasRenderer } from '@01.software/sdk/ui/canvas'
import { VideoPlayer } from '@01.software/sdk/ui/video'The root entry keeps createClient, commerce helpers, collection helpers, and
types lightweight. Server, React Query, and UI features live behind explicit
sub-paths so consumers install feature peers only when they import the matching
entry.
| Import | Feature(s) | Install when used |
| ---------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| @01.software/sdk | browser-safe createClient, commerce helpers, collection helpers, types | none |
| @01.software/sdk/client | browser-safe createClient entry and SDK error guards | none |
| @01.software/sdk/server | createServerClient, server-only APIs, and SDK error guards | none; keep secretKey code on the server |
| @01.software/sdk/errors | SDK error classes and guards | none |
| @01.software/sdk/analytics | browser analytics client and typed event helpers | none |
| @01.software/sdk/metadata | SEO metadata extraction and generation helpers | none |
| @01.software/sdk/webhook | webhook handlers, event guards, and webhook types | none |
| @01.software/sdk/query | React Query hooks, cache helpers, getQueryClient | @tanstack/react-query, react, react-dom |
| @01.software/sdk/realtime | RealtimeConnection, useRealtimeQuery | @tanstack/react-query, react, react-dom |
| @01.software/sdk/storefront-cache | product storefront cache resource name helpers | none |
| @01.software/sdk/embedded-admin | embedded app browser handshake and shared protocol types | none |
| @01.software/sdk/embedded-admin/server | assertion verification and JWKS loading | none; keep receiver session creation on the server |
| @01.software/sdk/analytics/react | <Analytics /> | react, react-dom |
| @01.software/sdk/ui/rich-text | RichTextContent, StyledRichTextContent | react, react-dom, @payloadcms/richtext-lexical |
| @01.software/sdk/ui/form | FormRenderer | react, react-dom |
| @01.software/sdk/ui/code-block | CodeBlock, highlight | react, react-dom, shiki, hast-util-to-jsx-runtime |
| @01.software/sdk/ui/canvas | CanvasRenderer, CanvasFrame, useCanvas, prefetchCanvas | react, react-dom, @tanstack/react-query, @xyflow/react, quickjs-emscripten, postcss, sucrase |
| @01.software/sdk/ui/canvas/server | canvas server helpers | none |
| @01.software/sdk/ui/video | VideoPlayer | react, react-dom, @mux/mux-player-react |
| @01.software/sdk/ui/image | Image | react, react-dom |
CanvasRenderer renders reserved shape nodes (terminator, process,
decision, io, subprocess) with first-party SVG geometry, without a tenant
catalog row or QuickJS template.
If a feature is not listed here, it does not need a separate peer install.
For the full component-to-peer mapping, see
packages/published/sdk/.claude/rules/components-reference.md.
Embedded Admin App
An embedded admin app is one tenant-owned management UI rendered inside the Console Developer Settings surface. The browser helper accepts exactly one short-lived Console assertion through the pinned parent/origin challenge. Send the receipt immediately to your own backend; do not decode it into a browser session or persist it.
import { receiveEmbeddedAdminSession } from '@01.software/sdk/embedded-admin'
const receipt = await receiveEmbeddedAdminSession({
consoleOrigin: 'https://console.01.software',
})
await fetch('/api/embedded-admin/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(receipt),
})The receiver backend verifies the assertion against the Console JWKS and pins its own exact origin. Establish an app-local session only after this succeeds.
import { verifyEmbeddedAdminSession } from '@01.software/sdk/embedded-admin/server'
const claims = await verifyEmbeddedAdminSession({
channelId: body.requestId,
consoleOrigin: 'https://console.01.software',
destination: 'https://admin.example.com',
token: body.token,
})Tenant admins configure the active tenant's single binding through the Human
CLI (01 embedded-app configure) with a user-scoped PAT. Binding management is
an operator control-plane action and is intentionally not exposed as a public
SDK mutation facade. A tenant API key (sk01_) is rejected.
Loopback HTTP is disabled by default. Only non-production fixtures may set
allowLoopbackHttp: true on the receiver and verifier options.
The assertion proves operator and tenant context only. It is not a Console API credential; the external backend must use its own server-held credential and authorization policy for tenant data.
Migration quick reference:
createClientremains available from@01.software/sdkand@01.software/sdk/client.createServerClientmust be imported from@01.software/sdk/server.- Error guards are available from the same
@01.software/sdk/clientor@01.software/sdk/serverentry as the client that throws; the root and@01.software/sdk/errorsguards also classify errors across entries. - React Query hooks and cache helpers must be imported from
@01.software/sdk/query. - Product storefront cache resource helpers must be imported from
@01.software/sdk/storefront-cache. - UI components must be imported from the specific
@01.software/sdk/ui/*sub-path and require only that row's peers. - Console-shared pure ecommerce helpers live in private
@01.software/contracts. The public SDK keeps customer-facing helpers self-contained and must not import private contracts; Console code should import shared helpers from contracts directly.
Analytics
// Pageviews + custom events (browser)
import { createAnalytics } from '@01.software/sdk/analytics'
const analytics = createAnalytics({ publishableKey: 'pk01_xxx' })
// auto-tracks pageviews; call analytics.pageview('/custom-path') manually if needed
analytics.track('signup', { plan: 'pro', trial: false })Custom events must be registered first. Each custom event name, its dimensions, and any enum/boolean value sets are defined per workspace in Console → Analytics. An unregistered or mistyped event is accepted by the browser (200/204) and then silently dropped server-side — no client error. Register the event before firing it.
Typed events (compile-time safety). Declare an event map as a plain type
(do not extends AnalyticsEventMap — an index signature would defeat
typo detection) and pass it as the generic. Mistyped names and out-of-enum
values then fail to compile:
import {
createAnalytics,
defineAnalyticsEvents,
} from '@01.software/sdk/analytics'
type ShopEvents = {
signup: { plan: 'free' | 'pro'; trial: boolean }
add_to_cart: { productId: string; price: number }
checkout_start: undefined // no props
}
const analytics = createAnalytics<ShopEvents>({ publishableKey: 'pk01_xxx' })
analytics.track('signup', { plan: 'pro', trial: false }) // ✅
analytics.track('checkout_start') // ✅ no props
// analytics.track('signpu', { plan: 'pro', trial: false }) // ❌ unknown event
// analytics.track('signup', { plan: 'team', trial: false }) // ❌ enum violation
// Bind the map once to avoid repeating the generic:
const create = defineAnalyticsEvents<ShopEvents>()
const a2 = create({ publishableKey: 'pk01_xxx' })React. Use AnalyticsProvider + useAnalytics() to fire events from
components. The provider auto-tracks pageviews like <Analytics/> and owns one
instance for its subtree:
Reusing the ShopEvents type from above:
import {
AnalyticsProvider,
useAnalytics,
} from '@01.software/sdk/analytics/react'
function Root() {
return (
<AnalyticsProvider>
<App />
</AnalyticsProvider>
)
}
function SignupButton() {
const { track, pageview } = useAnalytics<ShopEvents>()
// pageview(path?) is available for manual SPA pageviews; the provider already auto-tracks pageviews
return (
<button onClick={() => track('signup', { plan: 'pro', trial: false })}>
Sign up
</button>
)
}<Analytics /> remains available as a pageview-only mount helper for apps that
do not fire custom events from components.
Send mode. mode: 'auto' (default) suppresses sends on local hosts
(localhost / 127.0.0.1 / *.local); 'production' always sends, even
locally; 'development' never sends. The hosted <script> snippet uses the
same vocabulary via window.__01_analytics__.mode or data-mode; its legacy
captureOnLocalhost: true flag is equivalent to mode: 'production' and is
honored only when mode is unset.
Getting Started
Client
import { createClient } from '@01.software/sdk'
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY,
})
// Fetch public-safe, card-ready catalog data through the shaped helper.
const page = await client.commerce.product.listingPage({
limit: 10,
})
const cards = page.cardsFor Payload-native reads that shaped helpers do not cover, use the advanced raw collection escape hatch.
Server Client
import { createServerClient } from '@01.software/sdk/server'
import { createServerQueryHooks } from '@01.software/sdk/query'
const server = createServerClient({
publishableKey: process.env.SOFTWARE_PUBLISHABLE_KEY,
secretKey: process.env.SOFTWARE_SECRET_KEY, // sk01_... opaque API key from Console
})
const serverQuery = createServerQueryHooks(server)
// Start the canonical Cart -> Checkout -> PaymentSession flow.
const { cart, cartToken } = await server.commerce.carts.create({ market: 'kr' })
await server.commerce.carts.addItem({
cartToken,
productId,
variantId,
quantity: 1,
expectedRevision: cart.revision,
idempotencyKey: crypto.randomUUID(),
})
const checkout = await server.commerce.checkouts.create({
cartToken,
customer: { email: '[email protected]' },
idempotencyKey: crypto.randomUUID(),
})
// SSR prefetch (server)
await serverQuery.prefetchQuery({
collection: 'products',
options: { limit: 10 },
})Provider-paid and manual Order creation are not part of the 0.45 SDK surface.
An Order is emitted only when the Checkout is placed. Canonical Order reads
expose line snapshots directly at order.items.
Always import createServerClient from @01.software/sdk/server so generated
code and bundlers do not blur the Secret Key boundary.
Tenant context introspection
Trusted server workflows can inspect the resolved tenant capability context without calling the Console endpoint directly:
const context = await server.tenant.context()
const productSchema = await server.tenant.collectionSchema('products')
console.log(context.features)
console.log(context.collections.active)
console.log(context.fieldConfigs)
console.log(productSchema.collection.customFields)Pass { includeCounts: true } only when document counts and webhook
configuration are needed. That mode bypasses the endpoint cache and performs
additional collection reads. Tenant introspection is server-only and is not
exported from the browser-safe root entry.
collection.customFields is optional and currently applies to customers and
products. It exposes storagePath: 'customData', active definition machine
keys and types, optional required: true, and select option storage values.
Operator labels and stored document values are excluded; this schema metadata
does not grant collection read or write access.
Server-rendered preview routes can use server.preview.detail() with the
short-lived preview token issued by Console:
const preview = await server.preview.detail(
{ collection: 'products', id: previewId },
{ previewToken },
)For product pages, server.commerce.product.previewDetail({ id }, {
previewToken }) returns the raw product detail payload for the saved
draft/unpublished record addressed by the preview token. detail() wraps the
published storefront payload in a { found, product | reason } result.
Getting storefront content
Use shaped content helpers when a browser storefront needs relationship-backed media for common public content. These helpers use the publishable key only; the server resolves the tenant, enforces the owning feature, excludes drafts, and returns allowlisted DTOs instead of raw Payload documents.
import { createClient } from '@01.software/sdk'
const client = createClient({ publishableKey: '<publishable-key>' })
const links = await client.content.links.list({
limit: 10,
categorySlug: 'social',
tagSlug: 'footer',
featured: true,
sort: 'title',
})
const gallery = await client.content.galleryItems.list({
gallerySlug: 'spring-lookbook',
limit: 24,
})content.links.list() calls GET /api/links/storefront; link DTOs include
display fields, categories/tags, thumbnail, and icon media. Operator-only
fields such as tenant, metadata, click counters, and private storage details
are omitted. Use categorySlug and tagSlug for stable storefront URLs when
available; categoryId and tagId are also supported. Expired links are
excluded by default, though visible link DTOs may include expiresAt so
storefronts can render time-sensitive copy.
content.galleryItems.list() calls GET /api/gallery-items/storefront;
gallery item DTOs include title, description, content, gallery reference, and
image media. Tenant, metadata, draft status, and storage/provider internals are
omitted. Gallery item reads require either gallerySlug or galleryId; prefer
gallerySlug for storefront routes. The default gallery item sort is manual,
matching curator order from the Console.
Both helpers return Payload-style pagination (docs, totalDocs, page,
limit, etc.). limit is bounded server-side to 1..100; invalid query,
publishable-key, feature, rate-limit, and server errors surface through the
SDK's typed error classes and preserve request IDs on client.lastRequestId.
SDK sort inputs are typed public allowlists: links support created/updated/
published dates, title, and featured ordering; gallery items support manual
curator order, created/updated dates, and title.
Use client.collections.from(...).find() only as the advanced raw collection
escape hatch. Browser publishable-key raw reads stay shallow (depth: 0,
joins: false) and are not for relationship-expanded media. Use shaped helpers
for storefront content media, or createServerClient() when a server route
needs full raw collection access with server credentials.
Getting product detail
The recommended way to fetch a single product is the shaped helper:
import { createClient } from '@01.software/sdk'
const client = createClient({
publishableKey: '<publishable-key>',
})
const result = await client.commerce.product.detail({
slug: 'every-peach-tee',
})
if (!result.found) {
return notFound()
}
const { product } = result
// product: { product, variants, options, brand, categories, tags, images, videos,
// featuredImage, priceRange, compareAtPriceRange, availableForSale, selectedOrFirstAvailableVariant }Browser detail() returns ProductDetailCatalogResult, the same public-safe
catalog detail shape as detailCatalog(): { found: true, product:
ProductDetailCatalog } | { found: false, reason }. The reason value is one of
not_found, not_published, or feature_disabled, so storefronts can choose
between a standard 404, preview CTA, or feature gating UI. Permission/auth
errors, including 403 tenant mismatches, still throw typed SDKError subclasses
and preserve request IDs through the existing lastRequestId / onRequestId
path.
Public product visibility is driven by product status and publishedAt.
Listing helpers return products with status: 'published' when publishedAt
is current or empty. Direct detail and checkout admission also allow
status: 'unlisted', so an unlisted product can be opened by slug/id without
appearing in PLP/search/curated listing helpers. Draft, archived, scheduled, or
malformed lifecycle states return { found: false, reason: 'not_published' }
from detail and are omitted from listings.
The successful browser product payload omits operational inventory fields such as
product.totalInventory, variant stock, and variant reservedStock. Use
stockSnapshot() / stockCheck() for live storefront inventory overlays, or use
createServerClient().commerce.product.detail() from trusted server code when a
full operational detail read is required.
Edge-cached catalog + live stock (storefront migration)
When a PDP or listing UI is served behind the Console Edge CDN, prefer the catalog/stock split instead of treating cached catalog responses as live inventory authorities.
import { createClient, mergeProductDetailWithStock } from '@01.software/sdk'
const client = createClient({ publishableKey: '<publishable-key>' })
const catalog = await client.commerce.product.detailCatalog({
slug: 'every-peach-tee',
})
if (!catalog.found) return notFound()
const variantIds = catalog.product.variants.map((v) => v.id)
const snapshot = await client.commerce.product.stockSnapshot({ variantIds })
const { product, stockMergeStatus } = mergeProductDetailWithStock(
catalog.product,
snapshot,
)
// stockMergeStatus: 'complete' | 'partial' — partial when a variant id is missing from snapshotListing UIs use the same pattern: listingPage() for PLP/search grids, or
listingGroupsCatalog({ productIds }) when curated product IDs are already
known, then stockSnapshot() (or stock-check at cart/checkout) for live
availability. Browser detail() is now catalog-safe as well; see ADR 0012
addendum in docs/decisions/0012-sdk-public-commerce-contract.md.
Product Listing Pages (PLP) — join-safe queries
Recommended path: Use commerce.product.listingPage() (or
createQueryHooks(client).useProductListingPage()) for greenfield storefront
PLPs. It wraps the cacheable listing-groups query endpoint, keeps the raw
Payload pagination response, and adds cards built with
buildProductListingCard(). The endpoint returns pre-grouped listing data and
avoids the top-level products.options / products.variants join truncation
that raw REST product queries hit by default.
const response = await client.commerce.product.listingPage({
search: 'shirt',
limit: 24,
filters: {
categoryIds: ['category-1'],
price: { min: 10000, max: 50000 },
availableForSale: true,
},
basePath: '/shop',
})
const cards = response.cardsUse commerce.product.listingGroupsCatalog({ productIds }) when product IDs
are already known, for example curated rails, recommendations, or editorial
sections:
const response = await client.commerce.product.listingGroupsCatalog({
productIds: ['product-1', 'product-2'],
})Server-auth escape hatch: When server code deliberately needs raw
products collection reads (bulk operations, custom filters, fields the helper
does not expose), use createServerClient() and spread
PRODUCT_PLP_FIND_OPTIONS to raise the default Payload join limit of 10:
import {
PRODUCT_PLP_FIND_OPTIONS,
projectProductToListingShape,
} from '@01.software/sdk'
import { createServerClient } from '@01.software/sdk/server'
const server = createServerClient({
publishableKey: process.env.SOFTWARE_PUBLISHABLE_KEY!,
apiUrl: process.env.SOFTWARE_API_URL!,
secretKey: process.env.SOFTWARE_SECRET_KEY!,
})
const now = new Date().toISOString()
const { docs } = await server.collections.from('products').find({
...PRODUCT_PLP_FIND_OPTIONS,
where: {
and: [
{ status: { equals: 'published' } },
{
or: [
{ publishedAt: { less_than_equal: now } },
{ publishedAt: { exists: false } },
{ publishedAt: { equals: null } },
],
},
],
},
limit: 24,
})
const listingProducts = docs.map(projectProductToListingShape)PRODUCT_PLP_FIND_OPTIONS sets joins.variants and joins.options to safe
limits with sort: '_order'. It cures top-level products.options and
products.variants join truncation but cannot cure the nested
options[].values.docs join — the Payload REST joins param is flat and
nested join limits require the listing-groups endpoint. This preset is not
accepted by publishable createClient().collections.from('products').find()
because browser-public raw reads are constrained to depth: 0 and
joins: false, and cannot use populate.
Product selection helpers
import {
buildProductHref,
buildProductOptionMatrixFromDetail,
getProductSelectionImages,
resolveProductSelectionFromMatrix,
} from '@01.software/sdk'
const matrix = buildProductOptionMatrixFromDetail(product)
const selection = resolveProductSelectionFromMatrix(
matrix,
{ search: '?opt.color=black&opt.size=s' },
undefined,
{ detail: product },
)
const images = getProductSelectionImages(selection) // object media only, deduped
const href = buildProductHref(product, {
optionSlug: 'color',
optionValueSlug: 'black',
})Selection media follows the resolved selection: a complete variant uses that variant's media first; a partial option selection uses selected option-value media first, then matching variant media, before falling back to listing or product media. This keeps listing-card selection links and detail-page images aligned without rebuilding media priority in storefront code.
Commerce media note (pool + galleries)
For new storefront work, prefer pool-pointer and gallery-aware resolution from
commerce.product.detail() + resolveProductSelection() (and
getProductSelectionImages() when a list is needed). Direct pre-ADR-0025 fields like
variant.thumbnail and option-value direct images are still accepted as
transitional input, but are no longer primary storefront media sources.
availableValuesByOptionSlug / availableValuesByOptionId include
availableStock, isUnlimited, and availableForSale per value so option UIs
can render stock state without recalculating from variants. Each entry also
exposes handoff-aligned aliases (exists == available, label == value) and
an optional Shopify-shaped swatch: { color, image } alongside flat thumbnail
and images. Only swatch.image falls back to the first entry in images when
thumbnail is absent; flat thumbnail and images on the value object are
unchanged from the matrix source. Option-value upsert and detail/matrix shapes use
nested swatch only (swatch.color for hex); flat swatchColor is rejected.
With React Query
import { createQueryHooks } from '@01.software/sdk/query'
const query = createQueryHooks(client)
const { data: product, isLoading } = query.useProductDetailBySlug(slug)Cache key is ['products', 'detail', { slug }]. Mutations on products, product-variants, product-options, product-option-values, brands, brand-logos, images, and related collections automatically invalidate this cache.
Selection URL contract
Use createProductSelectionCodec(detail) when product pages need to keep option
selection in the URL. By default, complete selections emit variant=<variantId>
and partial selections emit slug-compat params such as ?opt.color=ivory.
Inbound canonical ID params (?opt.<optionId>=<valueId>) and compatibility slug
params (?opt.<optionSlug>=<valueSlug>) still parse. Plain bare keys such as
?color=ivory are rejected.
import {
createProductSelectionCodec,
resolveProductSelection,
} from '@01.software/sdk'
const codec = createProductSelectionCodec(product)
const normalizedSelection = codec.parse('?opt.color=ivory')
const selection = resolveProductSelection(product, normalizedSelection)
const selectionQuery = codec.stringify(normalizedSelection)
// selectionQuery === 'opt.color=ivory' for partial selections
// selectionQuery === 'variant=variant-black-s' once a complete variant is selected
// selection.selectedVariant, selection.price, selection.stock, selection.mediaEmpty vs partial selection
When selection input is omitted, resolveProductSelection() applies
selectedOrFirstAvailableVariant so PDP defaults match listing cards. That is
separate from fillDefaults and is not gated by a flag.
// PDP default (uses selectedOrFirstAvailableVariant when selection is omitted)
resolveProductSelection(product)
// Catalog: keep price range / no concrete variant
resolveProductSelection(product, { valueIds: [] })By default, partial selections (for example color only) leave
selectedVariant as null. Opt in to Shopify-style
selectedOrFirstAvailableVariant behavior with fillDefaults: true:
const resolution = resolveProductSelection(
product,
codec.parse('?opt.color=ivory'),
{
fillDefaults: true,
},
)
// resolution.selectedVariant is concrete; unselected options are filled
// using the same available-by-order rule that derives selectedOrFirstAvailableVariant.For option-click handlers, use selectNext() to apply a slug transition,
keep compatible prior selections, and re-default incompatible ones.
selectNext() already fills missing options internally:
import { resolveProductSelection, selectNext } from '@01.software/sdk'
const nextSelection = selectNext(product, currentSelection, 'color', 'ivory')
const resolution = resolveProductSelection(product, nextSelection)Use fillDefaults: true on resolveProductSelection() when you have a
partial URL or selection state and need a concrete variant without calling
selectNext(). It does not change codec parse/stringify behavior.
Opt out of slug-compat outbound URLs with
createProductSelectionCodec(product, { emit: 'canonical-id' }).
Normalized selection state uses stable option/value/variant IDs internally. Slugs in URLs are a compatibility/readability layer, not the identity source.
For listing cards, pass the listing group returned by
buildProductListingGroupsByOption() or the listing-groups endpoint into
buildProductHref(product, group, { detail }). Listing swatch hrefs emit
partial slug-compat hints such as ?opt.color=ivory by default. When full
detail is not available on a product-list page, pass the group without
detail; buildProductHref() still emits the best available selection hint and
the detail page can resolve it through resolveProductSelection().
Use preferCompleteVariantFromHint: true on buildProductHref() only when a
listing card should deep-link a complete hint variant instead of a color-only
partial hint.
Do not use bare option query keys such as ?size=large. The SDK rejects them
as ambiguous because product pages commonly share URLs with unrelated search,
filter, analytics, or framework parameters. Namespacing selection keys under
opt. lets the codec distinguish product-option state from ordinary query
parameters while still allowing unrelated parameters such as utm_campaign to
coexist without being interpreted as selection state.
For SEO, treat the product path without selection params as the canonical URL. Selection query params are share/deep-link state, not index targets.
Product listing card helper
buildProductListingCard(item, options?) turns a single
commerce.product.listingPage() or listingGroupsCatalog() response item into
a render-ready ProductListingCard. Each item includes listingGroupingState (grouped,
no_primary_option, or empty) and, when empty, listingGroupingEmptyReason
(primary_option_not_linked, primary_option_has_no_values, or
no_variants_for_primary_option). Each group includes public-safe
variants[] alongside variantIds/variantCount so storefronts can render or
inspect grouped variant fields without a follow-up fetch. The by-ids response
also returns missing: string[] for requested product IDs that were not found,
not published, or not accessible; docs preserve the input productIds order
for returned products. The helper populates optional representativeVariant, a
PDP-seeded href, representative media from Shopify-shaped
product.featuredImage with product gallery fallback, Product-level
priceRange / compareAtPriceRange, Product-level availableForSale, and a
swatches[] array derived from groups when there is more than one. Single-group
products emit swatches: []; storefronts that disagree can read item.groups
directly.
buildProductListingCard() derives card swatches from listing-group
optionValueSwatch. Image swatches use swatch.mediaItemId; color swatches use
swatch.color. Option-value thumbnail/gallery fields are no longer part of the
public listing-group or product-detail contract. New PLP filters and sorts
should use Product-shaped names such as priceRange.minVariantPrice.amount,
priceRange.maxVariantPrice.amount, and availableForSale.
import {
buildProductListingCard,
type ProductListingCard,
} from '@01.software/sdk'
const cards: ProductListingCard[] = response.docs.map((item) =>
buildProductListingCard(item, { basePath: '/shop' }),
)The card href is the product path by default; the PDP resolves the
representative variant through resolveProductSelection(detail) without a
selection param. Each swatch carries a hint-only slug-compat href such as
?opt.color=ivory; the detail page resolves it through
resolveProductSelection(detail, { search }). Use
preferCompleteVariantFromHint: true on buildProductListingCard() only when
the card should deep-link a complete hint variant.
Memberships
Membership self-service is split by credential boundary:
- Browser/customer JWT clients can read the signed-in customer's current or latest membership and request cancellation for active, trialing, or past-due memberships.
- Server clients with a Secret Key can subscribe a customer after the storefront has completed the delegated first charge.
import { createClient } from '@01.software/sdk'
import { createServerClient } from '@01.software/sdk/server'
const client = createClient({ publishableKey: 'pk01_...' })
const membership = await client.memberships.me()
if (
membership &&
['active', 'trialing', 'past_due'].includes(membership.status)
) {
await client.memberships.cancel({ id: membership.id })
}
const server = createServerClient({
publishableKey: 'pk01_...',
secretKey: process.env.SOFTWARE_SECRET_KEY!,
})
await server.memberships.subscribe({
planId: 'plan_123',
customerId: 'customer_123',
customerRef: 'cus_provider_123',
pgPaymentId: 'pay_123',
amount: 9900,
currency: 'KRW',
subscribeIntentKey: 'checkout-session-123',
card: { last4: '4242', brand: 'visa' },
})subscribeIntentKey is sent as X-Idempotency-Key; it is not part of the JSON
body. Replays must use the same plan, customer reference, amount, currency, and
paid-start provider payment id snapshot as the original incomplete membership.
Trial subscriptions may omit pgPaymentId because no first-period charge has
been captured yet; paid starts must include the provider payment id that funded
the first period.
memberships.me() and browser memberships.cancel() require the customer JWT
configured on the client. Refund webhook refundedAmount values are cumulative
charge totals, not per-event deltas.
Storefront performance defaults
- PLP: prefer
commerce.product.listingPage()oruseProductListingPage()(GET/api/products/listing-groups/query/catalog, CDN-cacheable, card-ready). UselistingGroupsCatalog({ productIds })only when IDs are already known. Treat the full listing-groups response shape as a server-auth escape hatch because it can include operational stock fields. Avoid fetching a product list and then callingdetail()per card. - PDP: prefer
useProductDetailBySlug()/commerce.product.detail(). OverridestaleTime/retryon the hook when you need fresher catalog data or faster failure on errors. - CDN-friendly reads: server/edge code can use
detailCatalog()andlistingGroupsCatalog()(GET, cacheable) plus batchedstockSnapshot()for live inventory. - React Query in the browser: default
getQueryClient()keeps SSR data fresh forever (staleTime: Infinity). For client-only storefronts, usegetStorefrontQueryClient()(~1 minute staleTime) when creating query hooks:
import { createClient } from '@01.software/sdk'
import {
createQueryHooks,
getStorefrontQueryClient,
} from '@01.software/sdk/query'
const client = createClient({ publishableKey: '...' })
const query = createQueryHooks(client, getStorefrontQueryClient())Advanced: direct Payload queries (escape hatch)
Most consumers should use the helper APIs above (commerce.product.detail, etc.). The query builder below is the escape hatch for advanced cases the helpers do not cover: bulk operations, custom filter combinations, or fields the helper response does not expose.
depth — how deep to populate relationship fields
depth is the primary control for populating relationships like category, images, brand. Browser publishable-key raw collection reads are constrained to depth: 0 with joins: false and no populate; relationship-rich storefront reads should use shaped helpers such as commerce.product.detail() / listingPage(), or a createServerClient() raw query when server credentials are appropriate. Browser SDK raw reads add those safe defaults automatically and reject relationship-expanded raw read options before making a request.
const product = await client.collections.from('products').findById(id, {
depth: 0,
joins: false,
})populate — which fields come back for populated relationships
populate controls which fields are returned per collection. It does NOT decide which relationships to populate — that is depth.
await server.collections.from('products').find({
depth: 2,
populate: {
categories: { title: true, slug: true },
images: { url: true, alt: true },
},
})joins — Payload join-field reverse-relations
joins is the correct control for Payload type: 'join' virtual reverse-relation fields. In this platform's SDK schema, browser-public relations such as products.variants, products.options, and article-authors.articles, plus server-auth relations such as customers.orders, customers.addresses, posts.comments, and orders.{items,transactions,fulfillments,returns}, are all join fields — you must use joins (not depth/populate) to control their pagination, sorting, filtering, and count. Internal backing joins such as product collection memberships are intentionally omitted from SDK collection types.
// Canonical product detail query — variants/options are join fields on Products
await server.collections.from('products').find({
where: { slug: { equals } },
joins: {
variants: { limit: 50, sort: '_order' },
options: {},
},
depth: 2, // also populate normal relationships (category, brand, etc.)
})
// Disable all join-field population for a lightweight list query
await client.collections.from('products').find({
depth: 0,
joins: false,
})Each join field defaults to limit 10 when joins is omitted. depth does not raise that cap — storefront PLPs that call products.find() with only depth and then buildProductListingGroupsByOption() can silently drop color swatches. Prefer listingPage() for PLP cards, or use PRODUCT_PLP_FIND_OPTIONS only in server-auth raw product queries (see PLP join-safe queries above).
Publishable-key browser raw reads must keep depth: 0, joins: false, and omit populate; relationship-expanded public storefront reads belong behind shaped helpers. Use createServerClient() for raw joins queries that need server credentials.
joins does NOT populate normal relationship fields. Keys that do not match a type: 'join' field on the queried collection are silently ignored — e.g. joins: { category: {} } on Products is a no-op because category is not a join field there. For normal relationships use depth (and optionally populate).
Filtering by relation
Use id-based filters as the default — they're the most reliable:
await client.collections.from('product-variants').find({
where: { product: { equals: productId } },
})Dotted-path filters (where: { 'product.slug': { equals } }) are Payload-native but may silently return empty when access control restricts the related document or when the relation is polymorphic.
Why did my query return empty?
Checklist when find() returns docs: [] unexpectedly, in order of likelihood:
- Access control filtered the document. Many collections enforce public read filters. Product listing reads require
status: 'published'plus a current or unsetpublishedAt. Draft, future,unlisted, archived, or malformed products silently disappear from raw listing results even when their slug or ID matches. Usecommerce.product.detail({ slug | id })forunlistedproducts. Correlate with backend logs viaclient.lastRequestId(or catchSDKError.requestId). - Build-time publishable key / API URL differs from runtime. SSG
generateStaticParams/generateMetadata/ the page render must all see the same tenant context. A wrong or missing key at build time produces a baked-in empty response. - Next.js SSG fetch cache served a stale empty response. Use
cache: 'no-store'orexport const revalidate = 0on server components that should reflect live data. where: { slug: 'x' }string shorthand. Always use{ slug: { equals: 'x' } }— bare strings silently match nothing.- Wrong key in
joins. Keys not matching atype: 'join'field on the queried collection are silently ignored (no error). For normal relationship fields usedepth/populate, notjoins. - Dotted-path relation filter (
where: { 'category.slug': { equals } }) under polymorphic or access-control constraints — switch to id-based filter:where: { category: { equals: id } }.
Usage in Next.js SSG / Server Components
- Create the client per request in server components. Avoid module-level singletons that could share state (customer token, cache) across unrelated requests.
depthimpacts static generation cost. Deeper populates = larger build payloads. Useselect/populateto trim response shape.- Cache interaction. SDK requests honor Next.js fetch caching. For pages that must reflect live data, set
cache: 'no-store'orexport const revalidate = 0on the route segment, or pass per-fetch options if you proxy the SDK behind your own fetcher.
// app/products/[slug]/page.tsx
import { createClient } from '@01.software/sdk'
export const revalidate = 60 // ISR — adjust per page freshness need
export default async function ProductPage({ params }) {
const client = createClient({
publishableKey: '<publishable-key>',
})
const result = await client.commerce.product.detail({ slug: params.slug })
if (!result.found) return notFound()
const { product } = result
// ...
}The SDK sends
Accept: application/vnd.01software.portone-action-capabilities.v1+json for
Standard create and browser/server PortOne retrieve. Hosted create and
browser/server cancel do not send it. It accepts either the negotiated exact action
or the released Console's exact action, normalizing the latter to
cancelable: false and retryPolicy: 'same_action_only'. Partial capability
fields, different literals, and unknown response fields still fail closed.
The Accept value must be that sole exact media type after optional surrounding
whitespace. Lists, wildcards, parameters, and q-values deliberately retain the
default projection; this makes either Console-first or SDK-first rollout safe.
Direct HTTP create/retrieve responses use the vendor Content-Type only when
the successful body actually contains the Standard capability action. Hosted,
Toss, cancel, confirm, and error responses remain application/json.
API
Client Configuration
const client = createClient({
publishableKey: string, // Required
apiUrl?: string, // Optional API origin override
})
const server = createServerClient({
publishableKey: string,
secretKey: string, // sk01_... or pat01_...
apiUrl?: string, // Optional API origin override
})| Option | Type | Description |
| ---------------- | -------- | ------------------------------------------------------------- |
| publishableKey | string | API publishable key |
| secretKey | string | API secret key or PAT (server only) |
| apiUrl | string | Optional API origin override for staging, preview, or proxies |
Use apiUrl: string when an SDK instance should target a non-default API
origin.
API URL resolution order:
- Explicit
apiUrlpassed tocreateClient()orcreateServerClient() SOFTWARE_API_URL(server) orNEXT_PUBLIC_SOFTWARE_API_URL(browser)- Build-time default:
DEFAULT_API_URLwhen injected at build time; otherwise dev-tagged SDK builds (-dev.versions) usehttps://api.stg.01.software, and regular releases usehttps://api.01.software
Query Builder
Access collections via client.collections.from(slug).
Note: the root
client.collections.from()type exposes the lightweight read surface (find,findById,count). Metadata helpers live behind the optional@01.software/sdk/metadataentry, and write operations (create,update,remove,updateMany,removeMany) are only available onserver.collections.from().
// List query - returns PayloadFindResponse
const { docs, totalDocs, hasNextPage } = await client.collections
.from('products')
.find({
limit: 20,
page: 1,
sort: '-createdAt',
depth: 0,
select: { title: true, slug: true },
})
// Query with populate/joins control
const { docs } = await client.collections.from('products').find({
select: { title: true, slug: true, price: true, thumbnail: true },
joins: false, // disable joins for lightweight list
})
// Override relationship populate and join expansion (server credentials only)
const product = await server.collections.from('products').findById(id, {
populate: { brands: { name: true, logo: true } },
joins: { variants: { limit: 50 } },
})
// Single item query - returns document directly
const product = await client.collections.from('products').findById('id')Localized raw collection reads can request a supported Payload locale. The SDK
currently exposes en and ko; it does not expose Payload's locale=all
shape. fallbackLocale serializes to Payload's REST fallback-locale query
key, and fallbackLocale: false serializes as fallback-locale=none.
const { docs } = await client.collections.from('articles').find({
locale: 'ko',
fallbackLocale: 'en',
limit: 10,
})
const article = await server.collections.from('articles').findById(id, {
locale: 'ko',
fallbackLocale: false,
})Raw collection mutations are an escape hatch. For ecommerce product catalog
writes, prefer server.commerce.product.upsert() so options, option-values,
and variants are written through the domain transaction.
// Create (server only) - returns PayloadMutationResponse
const { doc, message } = await server.collections
.from('articles')
.create({ title: 'Article' })
// Create with file upload (server only) - uses multipart/form-data
const { doc } = await server.collections
.from('images')
.create({ alt: 'Hero image' }, { file: imageFile, filename: 'hero.jpg' })
// Update (server only) - returns PayloadMutationResponse
const { doc } = await server.collections
.from('articles')
.update('id', { title: 'Updated article' })
// Update with file replacement (server only)
await server.collections
.from('images')
.update('id', { alt: 'New alt' }, { file: newFile })
// Delete (server only) - returns document directly
const deletedDoc = await server.collections.from('articles').remove('id')
// Count
const { totalDocs } = await client.collections.from('products').count()
// SEO Metadata (generate from a fetched document)
import { extractSeo, generateMetadata } from '@01.software/sdk/metadata'
const { docs } = await client.collections.from('products').find({
where: { slug: { equals: 'my-product' } },
limit: 1,
depth: 0,
})
const metadata = docs[0]
? generateMetadata(extractSeo(docs[0]), { siteName: 'My Store' })
: null
// Bulk operations (server only)
await server.collections.from('articles').updateMany(where, data)
await server.collections.from('articles').removeMany(where)API Response Types (Payload Native)
The SDK returns Payload CMS native response types without wrapping:
// find() returns PayloadFindResponse<T>
interface PayloadFindResponse<T> {
docs: T[]
totalDocs: number
limit: number
totalPages: number
page: number
pagingCounter: number
hasPrevPage: boolean
hasNextPage: boolean
prevPage: number | null
nextPage: number | null
}
// create() / update() returns PayloadMutationResponse<T>
interface PayloadMutationResponse<T> {
message: string
doc: T
errors?: unknown[]
}
// findById() / remove() returns T (document directly)| Operation | Response Type |
| ------------ | ------------------------------------------------------------------ |
| find() | PayloadFindResponse<T> - { docs, totalDocs, hasNextPage, ... } |
| findById() | T - document object directly |
| create() | PayloadMutationResponse<T> - { doc, message } |
| update() | PayloadMutationResponse<T> - { doc, message } |
| remove() | T - deleted document object directly |
| count() | { totalDocs: number } |
React Query Hooks
React Query helpers are opt-in through @01.software/sdk/query. Install
@tanstack/react-query (and React peers) only when your app imports this
sub-path. Browser components should use createQueryHooks(client) for
browser-safe reads and customer auth hooks. Collection writes belong in trusted
server code via createServerClient.
import { createQueryHooks } from '@01.software/sdk/query'
const query = createQueryHooks(client)
// List query
const { data, isLoading } = query.useQuery({
collection: 'products',
options: { limit: 10 },
})
// Suspense mode
const { data } = query.useSuspenseQuery({
collection: 'products',
options: { limit: 10 },
})
// Query by ID
const { data } = query.useQueryById({
collection: 'products',
id: 'product_id',
})
// Infinite scroll
const { data, fetchNextPage, hasNextPage } = query.useInfiniteQuery({
collection: 'products',
options: { limit: 20 },
})
// SSR Prefetch
await query.prefetchQuery({
collection: 'products',
options: { limit: 10 },
})
await query.prefetchQueryById({
collection: 'products',
id: 'product_id',
})
await query.prefetchInfiniteQuery({
collection: 'products',
pageSize: 20,
})
// Cache utilities
query.invalidateQueries('products')
query.getQueryData('products', 'list', options)
query.setQueryData('products', 'detail', id, data)
// Customer auth hooks (Client only)
const { data: profile } = query.useCustomerMe()
const { mutate: login } = query.useCustomerLogin()
const { mutate: register } = query.useCustomerRegister()
const { mutate: logout } = query.useCustomerLogout()
login({ email: '[email protected]', password: 'password' })
// Other customer mutations
query.useCustomerForgotPassword()
query.useCustomerResetPassword()
query.useCustomerChangePassword()
// Customer cache utilities
query.invalidateCustomerQueries()
query.getCustomerData()
query.setCustomerData(profile)// Server action / API route for collection writes
import { createServerClient } from '@01.software/sdk/server'
const server = createServerClient({
publishableKey: process.env.SOFTWARE_PUBLISHABLE_KEY!,
secretKey: process.env.SOFTWARE_SECRET_KEY!,
})
await server.collections.from('articles').update('article_id', {
title: 'Updated article',
})Customer Auth
New storefronts should use hosted customer OAuth through
client.customer.oauth.*. The SDK generates PKCE authorization URLs, parses the
callback, exchanges codes, rotates refresh tokens, and calls hosted logout, but
it does not store tokens for you. Store OAuth access, refresh, and transient
state in trusted route handlers, preferably HttpOnly cookies or a server-side
session store. Do not put OAuth tokens in browser-readable storage.
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY,
customer: {
persist: false,
oauth: {
issuer: process.env.NEXT_PUBLIC_SOFTWARE_CUSTOMER_AUTH_ISSUER!,
clientId: process.env.NEXT_PUBLIC_SOFTWARE_CUSTOMER_CLIENT_ID!,
},
},
})
const customerOAuth = client.customer.oauth
if (!customerOAuth) throw new Error('Customer OAuth is not configured')
const authorization = await customerOAuth.createAuthorizationUrl({
redirectUri: `${origin}/api/auth/customer/callback`,
returnTo: '/account',
})
const callback = customerOAuth.parseCallback(callbackUrl, {
expectedState: authorization.state,
})
const tokens = await customerOAuth.exchangeCode({
code: callback.code,
codeVerifier: authorization.codeVerifier,
redirectUri: `${origin}/api/auth/customer/callback`,
})
await customerOAuth.refresh({ refreshToken: tokens.refreshToken })
await customerOAuth.logout({ refreshToken: tokens.refreshToken })exchangeCode() may return an idToken when the openid scope is granted. In
this SDK release, treat idToken as opaque unless your server verifies issuer,
audience, expiry, nonce, and hosted JWKS signature itself. Use me() after
establishing an access token for trusted customer profile display.
Legacy direct local auth
client.customer.auth.* remains available for existing local email/password
flows: register, login, refresh, password reset, profile read/update, and
password change. Keep this path as explicit legacy compatibility for existing
integrations; do not overload login() to sometimes redirect into hosted auth.
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY,
customer: { persist: true },
})
// Register & login
const { customer } = await client.customer.auth.register({
name: 'John',
email: '[email protected]',
password: 'secure123',
})
const { token, customer } = await client.customer.auth.login({
email: '[email protected]',
password: 'secure123',
})
// Profile & token management
const profile = await client.customer.auth.me()
client.customer.auth.isAuthenticated()
client.customer.auth.logout()
// Authenticated customer's own orders (Client-only)
const orders = await client.commerce.orders.listMine({
page: 1,
limit: 10,
financialStatus: 'paid',
})
// Password
await client.customer.auth.forgotPassword('[email protected]')
await client.customer.auth.resetPassword(token, newPassword)
await client.customer.auth.changePassword(currentPassword, newPassword)forgotPassword() keeps the same SDK signature in both delivery modes. A
tenant administrator chooses either backward-compatible signed webhook
delivery or platform-managed email in the Console customer-authentication
settings. Platform email requires the storefront reset URL to be configured;
the platform appends the token query parameter and sends through the verified
tenant sender or the platform fallback. Do not add reset tokens or dynamic
redirect URLs to the SDK call.
Market discovery
Discover a tenant's active buyer markets and resolve the default market. These reads are safe for a publishable key — server-only FX/pricing fields are never returned.
const markets = await client.markets.list() // MarketDTO[] (active markets)
const primary = await client.markets.default() // MarketDTO | null (active primary)
// Pass the resolved handle to market-aware commerce calls:
const cart = await client.commerce.carts.create({ market: primary?.handle })MarketDTO is { id, handle, name, countryCode, targetCountries, currency,
isPrimary, isActive } — countryCode/targetCountries are ISO 3166-1 alpha-2,
currency is ISO 4217.
client.markets.default()resolves the tenant's primary market from server data. It is distinct from the client's configured default market handle (CommerceClient.getDefaultMarket()), which is the handle you passed tocreateClient({ market }).
Commerce Market Context
Pass market when constructing a client to default market-aware product and cart
helpers to that market. Per-call market still wins when supplied.
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY,
market: 'kr',
})
await client.commerce.product.detail({ slug: 'shirt' }) // uses kr
await client.commerce.carts.create() // creates a kr cart
await client.commerce.product.detail({ slug: 'shirt', market: 'us' }) // override
const usClient = client.withMarket('us')
const { cart } = await usClient.commerce.carts.retrieve({ cartToken })
await usClient.commerce.carts.addItem({
cartToken,
productId,
variantId,
quantity,
expectedRevision: cart.revision,
idempotencyKey: crypto.randomUUID(),
})createServerClient({ market }) and server.withMarket(market) provide the
same request-scoped default for trusted server code.
Commerce Checkout and PaymentSession
The canonical payment lifecycle is Cart → Checkout → PaymentSession → Order.
Checkout and PaymentSession results are required discriminated unions: narrow
their state fields before reading state-specific data such as nextAction or
order. Browser calls use publishable-key capability tokens and the matching
customer JWT when a resource is customer-bound. Server calls use the secret
credential and never expose provider secrets in returned DTOs.
Browser and server commerce writes use separate transports even when their JSON
shapes match. createServerClient() routes every Cart operation, Checkout
create/retrieve/query, and PaymentSession create/retrieve/cancel/confirm through
the resource-owned /server/* namespace. Public paths retain only their exact
capability/customer bodies and never advertise the server credential.
Generate one idempotency key per logical mutation, persist it with the pending
operation, and reuse it after timeouts or lost responses. Creating a fresh key
for each retry starts a different operation. Do not derive ordinary keys from a
Cart, Checkout, PaymentSession, Order, or provider id: reuse of that resource for
a later operation would collide. Use createCommerceIdempotencyKey() and retain
the generated value with the pending operation.
Cart add/update/remove/clear/discount/attribute mutations additionally require
the last observed cart.revision as expectedRevision. A
cart_revision_conflict means refetch the Cart and start a new logical mutation
with a new idempotency key. Cart merge requires the key but not a single revision
because it atomically resolves two current Cart states.
Treat a replayed Cart result as an operation receipt: never replace a newer
cached Cart with a lower revision; retrieve when response ordering is unclear.
import { createCommerceIdempotencyKey } from '@01.software/sdk'
import { isCommerceSDKError } from '@01.software/sdk/errors'
const checkoutIdempotencyKey = createCommerceIdempotencyKey()
const { checkout, checkoutToken } = await client.commerce.checkouts.create({
cartToken,
customer: { email: '[email protected]', name: 'Buyer' },
shippingAddress,
idempotencyKey: checkoutIdempotencyKey,
})
const paymentIdempotencyKey = createCommerceIdempotencyKey()
const paymentSession = await client.commerce.paymentSessions.create({
checkoutToken,
provider: 'toss',
providerOptions: { profile: 'widget_v2' },
idempotencyKey: paymentIdempotencyKey,
})
if (
paymentSession.presentationStatus === 'actionable' &&
paymentSession.nextAction.type === 'toss_widget'
) {
// Initialize the Toss widget with the returned public client/variant fields.
}
// PortOne Hosted V2 uses the same canonical lifecycle with a provider-specific
// HTTPS action. Redirect completion is not payment finality; retrieve the
// PaymentSession and let Console converge from PortOne's authenticated API.
const portOneSession = await client.commerce.paymentSessions.createPortOne({
checkoutToken,
provider: 'portone',
providerOptions: { profile: 'hosted_v2', paymentMethod: 'CARD_KR' },
idempotencyKey: createCommerceIdempotencyKey(),
})
if (
portOneSession.presentationStatus === 'actionable' &&
portOneSession.nextAction.type === 'portone_checkout'
) {
window.location.assign(portOneSession.nextAction.checkoutUrl)
}
// Standard V2 is a distinct Browser SDK capability, not a Hosted fallback.
// Do not treat the SDK result or mobile redirect as payment finality.
const standardPortOneSession =
await client.commerce.paymentSessions.createPortOne({
checkoutToken,
provider: 'portone',
providerOptions: { profile: 'standard_v2', paymentMethod: 'CARD' },
idempotencyKey: createCommerceIdempotencyKey(),
})
if (
standardPortOneSession.presentationStatus === 'actionable' &&
standardPortOneSession.nextAction.type === 'portone_sdk'
) {
const { cancelable, retryPolicy, ...paymentRequest } =
standardPortOneSession.nextAction
// PortOne V2 requires its currency constant (`CURRENCY_KRW`), not ISO `KRW`.
// A Standard action cannot be canceled or replaced. After browser failure,
// retry only this exact payment request and paymentId instead of calling
// cancelPortOne() or creating a sibling PaymentSession.
if (!cancelable && retryPolicy === 'same_action_only') {
// await PortOne.requestPayment(paymentRequest)
}
// Always retrieve the session afterwards; Console lookup/webhook is authoritative.
}
// Browser recovery requires the parent capability.
await client.commerce.paymentSessions.retrieve({
paymentSessionId: paymentSession.id,
checkoutToken,
})
await client.commerce.paymentSessions.retrievePortOne({
paymentSessionId: portOneSession.id,
checkoutToken,
})
// The server equivalent requires a write-capable credential because the
// provider-authoritative lookup can converge and finalize domain state.
await server.commerce.paymentSessions.retrievePortOne({
paymentSessionId: portOneSession.id,
})
// Operator recovery can start from PortOne's canonical provider identity.
// `findPortOne` is strictly local/read-only. `reconcilePortOne` is an explicit
// mutation and must reuse one durable key for retries of the same operation;
// its domain result and response complete atomically before delivery.
const portOneIdentity = {
provider: 'portone' as const,
providerAccountKey: 'store-id-from-the-registered-connection',
environment: 'test' as const,
providerPaymentId: 'provider-assigned-payment-id',
}
const localPortOneState =
await server.commerce.paymentSessions.findPortOne(portOneIdentity)
const reconciledPortOneState =
await server.commerce.paymentSessions.reconcilePortOne({
...portOneIdentity,
idempotencyKey: createCommerceIdempotencyKey(),
})
// Toss confirmation is a typed server-only adapter action. There is no generic
// `confirmVerified` or provider-paid Order creation escape hatch.
const confirmationIdempotencyKey = createCommerceIdempotencyKey()
let confirmed
try {
confirmed = await server.commerce.paymentSessions.confirm({
paymentSessionId: paymentSession.id,
idempotencyKey: confirmationIdempotencyKey,
action: {
type: 'toss_authorization',
paymentKey,
providerOrderId,
},
})
} catch (error) {
if (isCommerceSDKError(error) && error.retryable) {
// Retry the same logical operation with confirmatio