01.software Docs

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.

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:

InputResponse
Unknown or wrong guestCartToken404/not_found
Another customer's Cart404/not_found
Another workspace's Cart404/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 inputResult
An active Cart the customer already owns, including an older Cartresolution: 'unchanged', with no writes
A guest Cart whose market differs from the chosen Cart409 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.

RequestResult
Missing or blank delivery informationHTTP 400 shipping_address_required, before Cart preparation or reservations.
Only non-shipping linesThe address can be omitted; shipping stays zero.
Input that does not match the request schemavalidation_failed.
Same idempotency key with an address added later409 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. true means the prices are saved but the listing data may not be updated yet.
  • price is 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_conflict requires 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 in details.body, with apiReason and requestId.
  • 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/orders query, set limit=1, and send the field data as the body. Local API callers use numeric limit: 1, or a strict UUID id.equals that bounds the whole predicate. An ID in only one OR branch is not enough. SDK updateMany has 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.

  1. Call server.commerce.orders.prepareFulfillmentOrder({ orderNumber }) for the paid order to move its open fulfillment orders to in_progress.
  2. After preparation succeeds, call server.commerce.orders.createFulfillment({ orderNumber, ...shipment }) with the same orderNumber. If you pass fulfillmentOrderId, 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 surfaceResource familyPublic SDK helper
product listing / product detailproductsproductListing(scope), productDetail(scope, { id }) or { slug }
linkslinksreserved; no public helper yet
documentsdocumentsreserved; no public helper yet
gallery itemsgallery-itemsreserved; no public helper yet
playlistsplaylistsreserved; no public helper yet
trackstracksreserved; no public helper yet
media/imagesimagesreserved; no public helper yet
shipping policiesshipping-policiesreserved; 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.

Next Actions

On this page