SDK
Use the TypeScript SDK for the default implementation path.
SDK
Use the SDK when you are building a frontend, server route, or workflow that should stay aligned with 01.software conventions.
Setup Flow
Install the package
Add @01.software/sdk to the project package manager already used by the repository.
Create the browser client
Use the Publishable Key only from approved origins. The browser client supports public reads plus capability- or customer-authorized Cart, Checkout, and PaymentSession operations.
Create the server client
Use the Secret Key only in trusted server code for writes and privileged workflows.
Verify the slice
Run the target project's typecheck, lint, tests, and build before handoff.
Client Choice
| Context | Client | Notes |
|---|---|---|
| browser UI | browser client | public reads and capability/customer-authorized commerce, origin-limited |
| server route | server client | import from @01.software/sdk/server for server-authoritative and privileged operations |
| React data fetching | query helpers | keep cache and loading states explicit |
| AI-generated code | existing project patterns | avoid adding framework layers |
Import Boundaries
Use @01.software/sdk for the browser-safe client, commerce helpers, and core
types. Use @01.software/sdk/server for createServerClient, and use
@01.software/sdk/query only when your app needs React Query helpers. Error
guards are available from the client/server entry that creates the client and
also work across the root and @01.software/sdk/errors entries.
Rows marked none do not need any extra packages beyond @01.software/sdk.
| 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 helpers | none |
@01.software/sdk/embedded-admin | embedded app browser handshake and protocol types | none |
@01.software/sdk/embedded-admin/server | assertion verification and JWKS loading | none; keep receiver sessions 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 the SDK package README.
Embedded Admin Apps
Use an embedded admin app for a tenant-specific management UI that should live inside Console, not for mandatory product capabilities. The current surface supports one binding per tenant.
import { receiveEmbeddedAdminSession } from '@01.software/sdk/embedded-admin'
import { verifyEmbeddedAdminSession } from '@01.software/sdk/embedded-admin/server'The iframe calls receiveEmbeddedAdminSession({ consoleOrigin }) once and
forwards the returned requestId and token to its own backend. The backend
calls verifyEmbeddedAdminSession({ channelId: requestId, consoleOrigin, destination, token }) before creating an app-local session. Pin both origins;
never store, log, or use the assertion as Console API authorization.
Binding management is an operator control-plane action and is intentionally not
exposed as a public SDK mutation facade. Tenant admins use 01 embedded-app
with a user PAT. Loopback HTTP is disabled by default; only non-production
fixtures may opt in with allowLoopbackHttp: true.
The assertion carries identity and tenant context only. Use a separate server-held credential and app authorization policy for tenant data.
Error reasons
Canonical commerce failures keep their closed, typed reason on
CommerceSDKError.reason. Other endpoints expose their open-ended machine
reason on SDKError.apiReason. Shared handlers can import
getSDKErrorReason from @01.software/sdk/errors to read either family without
inspecting details.reason or details.body.reason; unknown commerce reasons
are returned verbatim from rawReason.
PortOne Standard V2 cancellation and retry
A portone_sdk next action declares cancelable: false and
retryPolicy: 'same_action_only'. Check those capabilities before calling a
PaymentSession cancellation method. Browser failure, abandonment, local expiry,
or an unsuccessful provider observation does not release the Checkout payment
slot or authorize a new payment ID.
The current SDK negotiates this capability projection on Standard create and
on browser/server PortOne retrieve, where the profile is unknown. Hosted create
and browser/server cancel do not send the capability Accept header. It also
accepts the released Console's exact Standard action and normalizes the
same fixed semantics, so SDK and Console upgrades do not require lockstep
deployment. Direct HTTP callers receive the released exact shape by default and
can opt in on create/retrieve with
Accept: application/vnd.01software.portone-action-capabilities.v1+json.
That value must be the sole exact media type after optional surrounding
whitespace. Accept lists, wildcards, parameters, and q-values deliberately stay
on the default projection. Responses are private and no-store, and only a
successful exact opt-in that actually returns a Standard capability action uses
the negotiated media type as Content-Type; Hosted, Toss, cancel, confirm, and
error responses remain application/json.
if (
session.presentationStatus === 'actionable' &&
session.nextAction.type === 'portone_sdk'
) {
const { cancelable, retryPolicy, ...paymentRequest } = session.nextAction
if (!cancelable && retryPolicy === 'same_action_only') {
// Retry this exact request; do not call cancelPortOne() or create a sibling session.
await PortOne.requestPayment(paymentRequest)
}
}Retrieve the same PaymentSession after the browser result. Only Console's authenticated PortOne lookup or webhook convergence determines payment finality.
Server-only PortOne identity reconciliation
createServerClient().commerce.paymentSessions.findPortOne/reconcilePortOne
locates a managed PortOne payment from its complete canonical identity:
provider + providerAccountKey + environment + providerPaymentId. Tenant
scope comes only from the server credential; the strict request body rejects a
caller-supplied tenant ID.
const selector = {
provider: 'portone' as const,
providerAccountKey,
environment: 'test' as const,
providerPaymentId,
}
const local = await server.commerce.paymentSessions.findPortOne(selector)
const reconciled = await server.commerce.paymentSessions.reconcilePortOne({
...selector,
idempotencyKey,
})findPortOne is local and read-only. reconcilePortOne requires write scope
and a durable idempotency key, and invokes the existing managed convergence
engine. Reuse the same key after an interrupted response: Console replays the
first atomically committed result without repeating provider I/O. Its
disposition is applied, observed,
recovered_after_ambiguous_result, or requires_attention. Both HTTP and SDK
use { found: false } for local not-found; reconciliation never claims or
heuristically attaches an unknown provider payment to a Checkout or Order.
Neither method is available on the browser client.
Server-only custom payment bridge
createServerClient().commerce.paymentSessions.custom.create/retrieve/confirm
supports a provider that has no first-party adapter. Keep this namespace and
its types behind @01.software/sdk/server: browser clients and the SDK root do
not expose it.
const recovered = await server.commerce.paymentSessions.custom.retrieve({
provider: 'portone',
by: { providerSessionId },
})
if (recovered.finalizationStatus === 'committed') {
console.log(recovered.orderId)
}retrieve accepts exactly one providerSessionId or providerPaymentId. It
performs an exact lookup and returns only bounded status, Money, and the
resolved orderId; it neither lists sessions nor exposes raw PaymentSession
relations, provider evidence, fingerprints, or credentials. The result is a
recovery observation, not proof that a provider capture is valid. Trusted
server code must still independently verify the provider API response or signed
webhook before confirming the exact captured identity and Money. This bridge is
neither a generic “mark paid” API nor a manual/offline pending-order placement
flow; see the SDK README and Commerce for the complete boundary.
Custom bridge sessions intentionally appear as null in
checkout.paymentState.activePaymentSession; persist the providerSessionId
from custom.create and use the exact lookup above as the supported custom-lane
settlement recovery path.
Idempotent mutation responses are not exactly-once gates
No commerce mutation response field is a safe exactly-once gate for a
non-idempotent side effect such as an operational alert, outbound webhook, or
ledger entry. Gate the side effect with a consumer-owned durable claim keyed by
its stable logical identity. Follow the processOnce webhook
pattern: use a database unique key, queue idempotency
store, or provider-safe send ledger rather than process memory or a response
field.
For paymentSessions.custom.confirm, alreadyConfirmed: true means the session
was already confirmed at observation time, not that the current call did or did
not cause the transition. The field is absent from the initial successful
confirmation. If you only need to interpret that response shape, test
'alreadyConfirmed' in result instead of result.alreadyConfirmed === false.
Do not use either branch to trigger a non-idempotent side effect: if the initial
confirmation commits but its response is lost, the retry returns
alreadyConfirmed: true and the transition would otherwise be silently missed.
For orders.cancelOrder, cancelCommitted and alreadyCanceled describe the
domain effect disposition, not transport replay. A retry with the same
idempotency key replays the cached initial 2xx body byte-for-byte, so it can
return the original cancelCommitted: true again. Neither field tells you
whether this response was replayed or whether a side effect has already run.
Tenant Context Introspection
Trusted server workflows can read the resolved tenant features, active and inactive collections, and field configuration through the server-only client:
import { createServerClient } from '@01.software/sdk/server'
const server = createServerClient({
publishableKey: process.env.SOFTWARE_PUBLISHABLE_KEY!,
secretKey: process.env.SOFTWARE_SECRET_KEY!,
})
const context = await server.tenant.context()
const productSchema = await server.tenant.collectionSchema('products')Use server.tenant.context({ includeCounts: true }) only when collection counts
and webhook configuration are required. It performs additional reads and is
slower than the default call. Keep this API and both credentials in trusted
server code.
productSchema.collection.customFields is optional. When present, it describes
active tenant-defined fields stored under customData using machine keys,
types, optional required markers, and select option storage values. It excludes
operator labels and stored document values; ordinary collection authorization
still governs reads and writes.
Customer Auth
Hosted customer OAuth is the default path for new storefronts. Configure the browser-safe client with the workspace publishable key plus customer auth issuer and client id:
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!,
},
},
})In a trusted route handler, first guard that client.customer.oauth is
configured, then call createAuthorizationUrl() to start PKCE. Exchange the
callback code server-side and store access, refresh, and transient OAuth state
in HttpOnly cookies or another server-owned session store. Do not place hosted
customer OAuth tokens in browser JavaScript, localStorage, or client-readable
cookies.
client.customer.auth.login() and register() remain available for legacy
direct email/password compatibility, but new ecommerce templates should expose
that path only behind explicit legacy routes.
For those direct local-auth integrations,
client.customer.auth.forgotPassword(email) supports either signed tenant
webhook delivery or platform-managed reset email. Choose the delivery mode and
configure the absolute storefront reset URL under the Console customer
authentication settings. Platform delivery appends the token query parameter
and uses the verified tenant sender when available. The SDK does not accept an
arbitrary redirect URL for this call.
Community public reads
Use the browser-safe shaped helpers for community list and detail pages:
const page = await client.community.listPosts({ limit: 20 })
const post = await client.community.getPost({ postId: page.docs[0].id })A publishable key is enough for these reads. getPost() returns the allowlisted
CommunityPost DTO and returns null for missing, hidden, or cross-tenant
posts. A customer JWT may accompany the request, but a secret key is never
needed. Raw client.collections.from('posts') remains server-only.
Event ranges and all-day dates
client.events.getRange() returns allDayStartDate and exclusive
allDayEndDateExclusive on each all-day event and occurrence. Render that date
pair when isAllDay is true. The instant pair is a server-derived
local-midnight compatibility/index shadow and must not be converted into the
viewer timezone to recover display dates. Timed rows keep startsAt/endsAt
authoritative and return both date fields as null.
Events Admission
Use client.events.getAdmissionAvailability({ occurrence }) to render public
paid-admission choices for one event occurrence.
import { createClient } from '@01.software/sdk'
const client = createClient({ publishableKey: 'pk01_xxx' })
const admission = await client.events.getAdmissionAvailability({
occurrence: 'occ_123',
})
const general = admission.admissionTypes[0]The response includes event/occurrence identity, admission type identity,
productId / variantId for the existing cart flow, public price presentment,
and a remaining-capacity hint. Each admission type row includes the requested
occurrenceId, including all-event admission types. It does not expose raw
priceReference, product inventory, holds, checkout/order records, payment
sessions, provider event ids, or order-money internals.
Checkout still uses ecommerce helpers: add the returned product/variant to a cart
with admissions: [{ eventId, occurrenceId, admissionTypeId }], then use the
canonical Cart → Checkout → PaymentSession flow. This helper is separate from
the event-commerce events.products projection.
The singular admission request/response alias was removed in SDK 0.45.0;
admissions is required for all Cart line input and responses.
Market context
Storefronts pass a market to price-and-currency-aware calls. Discover markets
and resolve the default with the markets namespace:
const markets = await client.markets.list()
const primary = await client.markets.default()
const cart = await client.commerce.carts.create({ market: primary?.handle })Only client-safe fields are returned (id, handle, name, countryCode,
targetCountries, currency, isPrimary, isActive); FX rates and pricing
adjustments stay server-side.
Analytics
@01.software/sdk/analytics tracks pageviews automatically and lets you fire
custom events with a single track() call.
import { createAnalytics } from '@01.software/sdk/analytics'
const analytics = createAnalytics({ publishableKey: 'pk01_xxx' })
// pageviews are tracked automatically
analytics.track('signup', { plan: 'pro', trial: false })Register events first. Custom event names, dimensions, and allowed values are defined per workspace in Console → Analytics. An unregistered or mistyped event is accepted by the browser but silently dropped server-side.
Typed events. Declare a plain type (do not extends AnalyticsEventMap
— an index signature defeats typo detection) and pass it as the generic.
Mistyped names and out-of-enum values 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' })
// Bind the map once to avoid repeating the generic:
const create = defineAnalyticsEvents<ShopEvents>()
const a2 = create({ publishableKey: 'pk01_xxx' })React. AnalyticsProvider + useAnalytics() fire events from components.
The provider auto-tracks pageviews 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 /> is the 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). Use 'production' to always send
(useful for local smoke tests) or 'development' to never send. The hosted
<script> snippet reads mode from window.__01_analytics__.mode or data-mode;
the legacy captureOnLocalhost: true flag is equivalent to mode: 'production'
and is honored only when mode is unset.
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 tenant-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'
const cacheScope = {
tenantId: tenant.id,
publishableKeyScope: 'default', // app-defined, short, non-secret scope
}
const listingTags = [storefrontCacheResources.productListing(cacheScope)]
const 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 tenant
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 tenant-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 tenant/key scopes short and non-secret.
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 })
return fetch(
`${process.env.SOFTWARE_API_URL}/api/products/detail?${params}`,
{
cache: 'force-cache',
headers: {
'X-Publishable-Key': process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
},
next: { tags },
},
)
}Webhook route handlers should revalidate the same resource names from an app-owned invalidation payload, not by issuing raw collection reads. This example assumes your route has normalized the platform webhook data into the product identity and public cache scopes your app uses:
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 async function POST(request: Request) {
const invalidation = (await request.json()) as 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 Response.json({ 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.
Before Production
- Confirm collection and feature availability for the workspace plan.
- Confirm key storage and rotation ownership.
- Confirm failure handling for customer-facing flows.
Do not put Secret Key values in browser bundles, public env vars, analytics events, or generated examples.
Next Actions
- Need exact HTTP contracts: open API.
- Need a shell-capable coding agent: open CLI.
- Need hosted, shell-less discovery: open MCP.
- Need event delivery: open Webhooks.
- Need ready-made commerce flows? See Commerce helpers.