01.software Docs

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 surfaceResource familyPublic SDK helper
product listing / product detailproductsproductListing(scope), productDetail(scope, { id }) or { slug }
linkslinksreserved family; no public helper yet
documentsdocumentsreserved family; no public helper yet
gallery itemsgallery-itemsreserved family; no public helper yet
playlistsplaylistsreserved family; no public helper yet
trackstracksreserved family; no public helper yet
media/imagesimagesreserved family; no public helper yet
shipping policiesshipping-policiesreserved 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.

Next Actions

On this page