Ecommerce SDK
Market context, product edits, orders and storefront caching.
Ecommerce SDK
For a complete task, start with the Ecommerce guide. This reference keeps the public SDK boundary; use SDK basics for imports and peers.
Existing variant base prices
The following helpers require SDK 0.66.0 or later and are unavailable in the published 0.65.1 artifact used by this page’s examples. Do not copy these calls into a 0.65.1 consumer; repository source availability is not publication.
With that version prerequisite, use the server-only server.commerce.product.graphRevision({ productId, locale })
to take a baseline before reviewing current variant values, then call
updateVariantPrices({ productId, locale, graphRevision, updates: [{ id, price }] }).
Use the same enabled content locale (ko or en) for baseline, value review,
save, and independent readback. The revision response contains only
{ productId, graphRevision } and is private/no-store.
The helper patches 1–250 existing rows and preserves the server's counts and
productProjectionStale: true means prices saved but the listing projection
may be stale. Price is the existing numeric shop-currency amount; Console
validates currency precision. Unsupported fields such as stock, deletions,
compare-at prices, market prices, and options are rejected before a request.
A 409 graph_conflict requires fresh review: there is no automatic revision
refresh, PATCH retry, or full-upsert fallback. Legacy conflict information stays
in the SDK error's details.body, with apiReason and requestId. Read-only keys
can read the baseline but cannot save. Full graph upsert remains separate.
See the SDK README's Commerce Product example for the complete read/edit/readback flow.
Orders update compatibility
Orders accept one-document updates only. Admin Panel bulk edits and generic
server.collections.from('orders').updateMany(...) are rejected before any
Order changes with HTTP 400 (errors[0].data.code: order_bulk_update_unsupported).
Use update(orderId, data) for an unconditional edit. For a conditional update,
keep the entire where predicate in the raw PATCH /api/orders query and set
limit=1; send the field data as the body. Local API callers use numeric
limit: 1, or a strict UUID id.equals that bounds the complete predicate.
An ID in only one OR branch is insufficient. SDK updateMany has no limit
option and is not a supported Orders CAS path.
A conditional miss returns docs: [], errors: []. A collected hook failure
instead returns HTTP 500 (errors[0].data.code: order_update_failed), with a
localized message and rollback of the supported single operation. Access and
workspace checks remain in force. Do not replace a conditional update with an
unconditional ID update or automatically retry payment capture. Separate
per-order edits do not promise atomic success across the whole selection.
Console Local API Orders updates require a live transaction. Setting
disableTransaction: true without a live external transaction rejects top-ID
and where updates before writes with HTTP 400
(errors[0].data.code: order_transaction_required). Remove the option to
use Payload's normal transaction, or pass a live external transaction;
a request transaction ID alone is insufficient.
Market context
Storefronts pass a market to price-and-currency-aware calls. Discover markets
and resolve the default with the markets namespace:
import { createClient } from '@01.software/sdk'
type Client = ReturnType<typeof createClient>
export async function createMarketCart(client: Client) {
const markets = await client.markets.list()
const primary = await client.markets.default()
if (!primary) return { kind: 'no-market' as const, markets }
const cart = await client.commerce.carts.create({ market: primary.handle })
return { kind: 'created' as const, cart }
}Only client-safe fields are returned (id, handle, name, countryCode,
targetCountries, currency, isPrimary, isActive); FX rates and pricing
adjustments stay server-side.
Storefront Cache Resources
Shaped storefront reads use framework-neutral resource names so adapters can map
the same invalidation contract to Next.js cache tags, CDN tags, or another cache
layer without baking framework APIs into the core SDK. In this section,
storefront means public workspace-facing reads for a website, app, docs site, or
commerce frontend; it is not limited to ecommerce product pages.
import { storefrontCacheResources } from '@01.software/sdk/storefront-cache'
export function productTags(
tenantId: string,
productSlug: string,
publishableKeyScope = 'default',
) {
const cacheScope = { tenantId, publishableKeyScope }
return {
listingTags: [storefrontCacheResources.productListing(cacheScope)],
detailTags: [
storefrontCacheResources.productDetail(cacheScope, { slug: productSlug }),
],
}
}Resource names use this shape:
storefront:v1:tenant:<tenantId>:key:<publishableKeyScope>:resource:<collection>:list
storefront:v1:tenant:<tenantId>:key:<publishableKeyScope>:resource:<collection>:detail:<id|slug>:<identity>The SDK intentionally exposes public helpers only for product shaped reads today. The other rows reserve the same resource-family grammar for first-party adapters that own those shaped reads; do not add public helpers until the shaped adapter surface exists.
| Read surface | Resource family | Public SDK helper |
|---|---|---|
| product listing / product detail | products | productListing(scope), productDetail(scope, { id }) or { slug } |
| links | links | reserved family; no public helper yet |
| documents | documents | reserved family; no public helper yet |
| gallery items | gallery-items | reserved family; no public helper yet |
| playlists | playlists | reserved family; no public helper yet |
| tracks | tracks | reserved family; no public helper yet |
| media/images | images | reserved family; no public helper yet |
| shipping policies | shipping-policies | reserved family; no public helper yet |
Use list resources for list pages, search pages, and shaped reads that aggregate
multiple documents. Use detail resources only when the adapter already has the
same public identity it used for the cache tag. Slug-based product pages can use
{ slug } before fetching the shaped detail response, so they do not need a raw
collection pre-read just to discover the product ID.
publishableKeyScope should be a stable, short, non-secret key id or
fingerprint. Use the default scope only when every publishable key for the workspace
sees the same public cache view. If a cache entry is tagged with a key-specific
scope, webhook revalidation must revalidate that same scope; for workspace-wide
mutations, revalidate every affected public key scope.
Preview, draft, customer-token, and server-credential reads must stay outside
the shared storefront cache. Error responses, permission failures, and
{ found: false } reads should be treated as no-store unless the adapter owns
a separate negative-cache policy with its own short TTL.
Next.js Mapping
In a Next.js adapter, pass these names to cached fetch calls with next.tags.
The current Next.js revalidateTag API requires a second argument; use "max"
for stale-while-revalidate behavior. Tags only participate in revalidation when
the response is stored in the Data Cache, so opt into caching for SSG/ISR reads
and keep preview, draft, and other dynamic reads as no-store. Next.js cache
tags are case-sensitive, must be 256 characters or shorter, and each fetch can
carry at most 128 tags; keep workspace/key scopes short and non-secret.
The public HTTP operation is GET /api/products/detail/catalog with a Publishable Key; /api/products/detail is a full server-credential response. The HTTP success body is ProductDetailCatalog, with title at catalog.product.title; the SDK wraps it in a discriminated result. Catalog variants omit stock and reservedStock. The generated OpenAPI contract includes this method/path, Publishable Key security and ProductDetailCatalog success schema. Errors and missing results must not enter a shared success cache. The cache adapter below must supply a negative-cache policy or bypass caching when absence must be fresh.
import type {} from 'next'
import type { ProductDetailCatalog } from '@01.software/sdk'
import { storefrontCacheResources } from '@01.software/sdk/storefront-cache'
export async function fetchProductDetail(productSlug: string) {
const scope = {
tenantId: process.env.APP_TENANT_ID!,
publishableKeyScope: process.env.STOREFRONT_CACHE_KEY_SCOPE ?? 'default',
}
const tags = [
storefrontCacheResources.productDetail(scope, { slug: productSlug }),
]
const params = new URLSearchParams({ slug: productSlug })
const response = await fetch(
`${process.env.SOFTWARE_API_URL}/api/products/detail/catalog?${params}`,
{
cache: 'force-cache',
headers: {
'X-Publishable-Key': process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
},
next: { tags },
},
)
if (!response.ok) {
const parsed: unknown = await response.json().catch(() => null)
const body = (parsed && typeof parsed === 'object' ? parsed : {}) as {
code?: string
reason?: string
}
const reason = body.reason ?? body.code
if (
response.status === 404 &&
(reason === 'not_found' || reason === 'not_published')
) {
return { found: false as const, reason }
}
// Feature denial, route/deployment 404s and other failures stay errors.
const error = new Error('Product catalog request failed')
throw Object.assign(error, {
status: response.status,
reason,
requestId: response.headers.get('x-request-id'),
retryAfter: response.headers.get('retry-after'),
})
}
// HTTP returns the catalog DTO directly, unlike the SDK's { found, product } wrapper.
const catalog = (await response.json()) as ProductDetailCatalog
return { found: true as const, catalog }
}This fence's error.retryAfter preserves the raw Retry-After header (string | null, including HTTP-date values); the SDK error's retryAfter is parsed into a number of seconds.
Webhook route handlers should revalidate the same resource names from an app-owned invalidation payload, not by issuing raw collection reads. Call this helper only after your route has authenticated the webhook and normalized its data into the product identity and public cache scopes your app uses. It does not accept unauthenticated request JSON; use the signed receiver in Webhooks:
import { revalidateTag } from 'next/cache'
import { storefrontCacheResources } from '@01.software/sdk/storefront-cache'
type ProductInvalidation = {
collection: 'products'
tenantId: string
publishableKeyScopes?: string[]
productId?: string
currentSlug?: string
previousSlug?: string
invalidateListing?: boolean
}
export function revalidateProduct(invalidation: ProductInvalidation) {
const scopes = (invalidation.publishableKeyScopes ?? ['default']).map(
(publishableKeyScope: string) => ({
tenantId: invalidation.tenantId,
publishableKeyScope,
}),
)
if (invalidation.collection === 'products') {
for (const scope of scopes) {
if (invalidation.invalidateListing) {
revalidateTag(storefrontCacheResources.productListing(scope), 'max')
}
if (invalidation.productId) {
revalidateTag(
storefrontCacheResources.productDetail(scope, {
id: invalidation.productId,
}),
'max',
)
}
for (const slug of [
invalidation.currentSlug,
invalidation.previousSlug,
]) {
if (!slug) continue
revalidateTag(
storefrontCacheResources.productDetail(scope, { slug }),
'max',
)
}
}
}
return { ok: true }
}The invalidation payload does not have to perform raw collection reads in the
adapter, but it must carry the public identity needed for the tags it wants to
invalidate. For slug changes, include the previous slug too. If your app cannot
track previous slugs yet, use productListing(scope) as a coarse fallback and
only tag product detail pages with the listing resource when you deliberately
accept that broader invalidation.