Ecommerce SDK
Market context, product edits, orders and storefront caching.
For a complete task, start with the Ecommerce guide. This page covers the public Ecommerce SDK surface; see SDK basics for imports and peers.
Product listing search
commerce.product.listingPage({ market: 'kr', search: 'shirt' }) trims the search text and matches product title or slug.
Cart merge access
carts.merge returns the same 404/not_found response for these inputs and leaves Cart and Checkout data unchanged:
| Input | Response |
|---|---|
Unknown or wrong guestCartToken | 404/not_found |
| Another customer's Cart | 404/not_found |
| Another workspace's Cart | 404/not_found |
This applies to browser customer credentials and the server variant's explicit customerToken. Branch on reason and use requestId for support correlation.
Default customer Cart
A customer can have more than one active Cart, because carts.create always creates a new Cart. carts.mine returns the active Cart with the latest createdAt, and carts.merge merges a guest Cart into that same Cart. When createdAt is equal, the Cart with the higher id is chosen.
| Merge input | Result |
|---|---|
| An active Cart the customer already owns, including an older Cart | resolution: 'unchanged', with no writes |
| A guest Cart whose market differs from the chosen Cart | 409 cart_market_mismatch; merge does not use an older Cart in the guest Cart's market instead |
Checkout shipping address
commerce.checkouts.create() uses the request shippingAddress when you send one, and otherwise the address stored on the Cart. When any line has requiresShipping !== false, recipientName, phone, postalCode, address, and detailAddress must be nonblank after trimming. A country code alone is not enough.
| Request | Result |
|---|---|
| Missing or blank delivery information | HTTP 400 shipping_address_required, before Cart preparation or reservations. |
| Only non-shipping lines | The address can be omitted; shipping stays zero. |
| Input that does not match the request schema | validation_failed. |
| Same idempotency key with an address added later | 409 idempotency_conflict, because the request body changed. |
After shipping_address_required, start a new Checkout with a valid address and a new idempotencyKey. The SDK's commerce error union includes this reason; older SDKs keep it as rawReason on unknown_commerce_error.
Existing variant base prices
Published SDK 0.67.0 includes these server helpers. The 0.65.1 release used by the guide examples does not, so do not copy these calls into a 0.65.1 app.
Call the server-only server.commerce.product.graphRevision({ productId, locale }) to take a baseline before you review current variant values, then call updateVariantPrices({ productId, locale, graphRevision, updates: [{ id, price }] }). Use the same enabled content locale (ko or en) for the baseline, the review, the save, and the follow-up read. The revision response contains only { productId, graphRevision } and is private and no-store.
- The helper updates 1–250 existing variants and returns the server's counts and
productProjectionStale.truemeans the prices are saved but the listing data may not be updated yet. priceis the numeric amount in the shop currency; Console validates its precision.- Fields it does not support, such as stock, deletions, compare-at prices, market prices, and options, are rejected before the request.
- A
409 graph_conflictrequires a fresh review. The SDK does not refresh the revision, retry the PATCH, or fall back to a full upsert. The error keeps the conflict details indetails.body, withapiReasonandrequestId. - Read-only keys can read the baseline but cannot save. Full graph upsert is a separate operation.
The SDK README's Commerce Product section shows the complete read, edit, and confirm flow.
Orders update compatibility
Orders are updated one document at a time. 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).
- For an unconditional edit, use
update(orderId, data). - For a conditional edit, keep the whole where predicate in the raw
PATCH /api/ordersquery, setlimit=1, and send the field data as the body. Local API callers use numericlimit: 1, or a strict UUIDid.equalsthat bounds the whole predicate. An ID in only one OR branch is not enough. SDKupdateManyhas no limit option and is not a supported conditional path for Orders.
A conditional miss returns docs: [], errors: []. A collected hook failure returns HTTP 500 (errors[0].data.code: order_update_failed) with a localized message, and the single supported operation is rolled back. Access and workspace checks still apply. Do not replace a conditional update with an unconditional ID update, and do not retry payment capture automatically. Separate per-order edits do not guarantee atomic success across the whole selection.
Console Local API Order updates need a live transaction. disableTransaction: true without a live external transaction rejects top-ID and where updates before any write, with HTTP 400 (errors[0].data.code: order_transaction_required). Remove the option so Payload starts its normal transaction, or pass a live external transaction; a request transaction ID alone is not enough.
Fulfillment preparation
server.commerce.orders.createFulfillment requires an in_progress fulfillment order. Payment alone leaves an open fulfillment order unprepared.
- Call
server.commerce.orders.prepareFulfillmentOrder({ orderNumber })for the paid order to move its open fulfillment orders toin_progress. - After preparation succeeds, call
server.commerce.orders.createFulfillment({ orderNumber, ...shipment })with the sameorderNumber. If you passfulfillmentOrderId, it must identify an in-progress fulfillment order belonging to that order.
Preparation needs no carrier or tracking information. createFulfillment does not prepare automatically; without an in-progress fulfillment order, it returns HTTP 400 not_fulfillable. Follow the error's prepareFulfillmentOrder guidance before creating the shipment.
Fulfillment mutation responses
BREAKING (0.x minor): createFulfillment and updateFulfillment return FulfillmentMutationResponse instead of the raw Fulfillment document. Shipment state, tracking and timestamps remain available.
tenant, order and fulfillmentOrder now contain IDs rather than populated documents. Legacy unlinked shipments return fulfillmentOrder: null; check it before reading that work order. The receipt omits items, adminNotes, metadata, method and deletedAt.
Replace shipment.items?.docs and other document-only reads with an authorized server.collections.from('fulfillments').findById(receipt.id, { joins: { items: { limit: 100 } } }) call. Fetch related details from their owning collection by the returned ID. Remove dependencies on internal method values and populated tenant internals; authorized collection reads retain their normal field access rules.
Completing an approved Return
On a server client, call server.commerce.orders.returnWithRefund with
existingReturnId and the matching orderNumber to complete an approved Return.
Keep the required returnItems and positive refundAmount fields. The server
uses the saved approval's items, amounts, fees and reason. refundAmount is the
line refund after return shipping/restocking fees; the initial shipping refund
is added separately. Retry a lost response with the same request and
idempotencyKey.
The response includes a limited Return receipt and refundSeq, plus a limited
refund Transaction when provider money moves. A completion without a provider
transaction has transaction: null and refundSeq: null. notificationWarning
reports a post-commit problem while the refund remains completed. Read full
Return or Transaction documents separately when needed. The SDK README lists
the receipt fields.
Market context
Storefronts pass a market to calls that depend on price and currency. List 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). Exchange rates and pricing adjustments stay on the server.
Storefront Cache Resources
Storefront reads use framework-neutral resource names, so an adapter can map one invalidation scheme to Next.js cache tags, CDN tags, or another cache layer without framework APIs in the core SDK. Here, "storefront" means any public frontend that reads workspace content, such as a website, app, docs site, or shop; it is not limited to 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>Today the SDK has public helpers only for product reads. The other rows reserve names for first-party adapters; they have no public helpers yet.
| Read surface | Resource family | Public SDK helper |
|---|---|---|
| product listing / product detail | products | productListing(scope), productDetail(scope, { id }) or { slug } |
| links | links | reserved; no public helper yet |
| documents | documents | reserved; no public helper yet |
| gallery items | gallery-items | reserved; no public helper yet |
| playlists | playlists | reserved; no public helper yet |
| tracks | tracks | reserved; no public helper yet |
| media/images | images | reserved; no public helper yet |
| shipping policies | shipping-policies | reserved; no public helper yet |
Use list resources for list pages, search pages, and reads that combine several documents. Use a detail resource 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 detail, so they do not need a raw collection read just to find the product ID.
publishableKeyScope should be a stable, short, non-secret key id or fingerprint. Use the default scope only when every Publishable Key in the workspace sees the same public content. If an entry is tagged with a key-specific scope, revalidate that same scope from the webhook. For workspace-wide changes, revalidate every affected key scope.
Keep preview, draft, customer-token, and server-credential reads out of the shared storefront cache. Treat errors, permission failures, and { found: false } results as no-store, unless the adapter has its own short-TTL negative cache.
Next.js Mapping
In a Next.js adapter, pass these names to cached fetch calls with next.tags. The current revalidateTag API needs a second argument; use "max" for stale-while-revalidate behavior. Tags take part in revalidation only when the response is in the Data Cache, so opt into caching for SSG/ISR reads and keep preview, draft, and other dynamic reads no-store. Next.js cache tags are case-sensitive, at most 256 characters, and limited to 128 per fetch; keep workspace and key scopes short and non-secret.
The public HTTP operation is GET /api/products/detail/catalog with a Publishable Key; /api/products/detail is the full response and needs a server credential. The HTTP success body is ProductDetailCatalog, with the title at catalog.product.title; the SDK wraps it in a discriminated result. Catalog variants have no stock or reservedStock. The generated OpenAPI contract includes this method and path, Publishable Key security, and the ProductDetailCatalog success schema. Keep errors and missing results out of the shared success cache: the adapter below needs a negative-cache policy, or must skip caching when absence has to 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 || 'https://api.01.software'}/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 }
}In this example, error.retryAfter keeps the raw Retry-After header (string | null, including HTTP dates); the SDK error's retryAfter is a number of seconds.
Webhook route handlers revalidate the same resource names from an app-owned invalidation payload, without raw collection reads. Call this helper only after your route has verified the webhook signature with the receiver in Webhooks and turned its data into the product identity and public cache scopes your app uses. It does not accept unverified request JSON:
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 must carry the public identity for every tag it invalidates. When a slug changes, include the previous slug too. If your app cannot track previous slugs yet, use productListing(scope) as a broad fallback, and tag product detail pages with the listing resource only if you accept that wider invalidation.