Storefront client — createStorefrontClient
The ergonomic, tenant-pinned entry point for building a headless storefront. It
wraps the low-level createFlightdeckClient and does two
things you would otherwise repeat by hand: it pins one tenant into every path
(so a call can never address another store), and it names the surface
(store.products.list() instead of client.request("get", "/v1/tenants/{tenant}/catalog/products", …)).
Return types and per-call argument shapes still flow from the generated OpenAPI
types, so a wrong query field or body shape is a compile error. On any non-2xx
the call throws FlightdeckError.
Construction
import { createStorefrontClient } from "@dscodotco/sdk";
const store = createStorefrontClient({
apiUrl: "https://api.ruo.pro", // the commerce API origin
tenant: "ruo-demo", // pinned into every call's {tenant} segment
storefrontToken: process.env.FLIGHTDECK_STOREFRONT_TOKEN!, // x-storefront-token
fetch, // optional; defaults to the global fetch
});Options — StorefrontClientOptions
| Option | Type | Required | Notes |
|---|---|---|---|
apiUrl | string | yes | The commerce API origin, e.g. https://api.ruo.pro. A trailing slash is trimmed. |
tenant | string | yes | Your store’s tenant_ref. Injected into the {tenant} segment of every call. Never read from request data (fail-closed tenancy). |
storefrontToken | string | yes | Sent as the x-storefront-token header. Checkout-authorizing and platform-wide today, not per-tenant. Server-side only. |
fetch | typeof fetch | no | Injectable fetch (tests, custom agents). Defaults to the global fetch. |
Never construct this client in browser code. The storefront token authorizes
placing orders and would ship in the bundle. Build it in a server runtime and
read the token from a server env var (FLIGHTDECK_STOREFRONT_TOKEN).
The returned client — StorefrontClient
The returned object exposes a read-only tenant string plus these namespaces:
site, products,
collections, search,
checkout, shopper, and
storeCredit. Every method returns a Promise of its own
precise, generated 2xx body and throws FlightdeckError on any non-2xx.
store.tenant; // "ruo-demo" — the pinned tenant ref, read-onlystore.site
The store’s active site manifest — the merchant-authored sections, nav, and theme tokens that make up the storefront’s public contract (ADR-0014).
site.get(): Promise<{ tenant_ref: string; version: number; manifest: SiteManifest }>| Parameter | Type | Required | Notes |
|---|---|---|---|
| (none) | The tenant is the pinned one. |
const { tenant_ref, version, manifest } = await store.site.get();
// manifest.template?.sections is the ordered home-page section listThe manifest is a SiteManifest — store, brand, theme, template
(with template.sections), seo, and behavior, each an open object. See
TypeScript usage for importing the
exact shape.
Fail-closed: an unknown tenant or a store that is not live throws
FlightdeckError with status: 404 — you never receive an empty manifest.
Render from the manifest; do not paper over a 404 with a hard-coded default.
store.products
products.list(): Promise<{ products: StorefrontProduct[] }>
products.get(slug: string): Promise<{ product: StorefrontProduct }>const { products } = await store.products.list();Returns the store’s active products, each with its variants. This
operation takes no query parameters in the current spec — its query type
is never, so list() is called with no argument. Paging and filtering of
the catalog are done through store.search.
The StorefrontProduct shape
Each product carries:
| Field | Type | Notes |
|---|---|---|
id, tenant_ref, slug, name | string | Identity. |
description | string | null | |
status | "draft" | "active" | "archived" | Storefront lists return active. |
image_asset_ref | string | null | The raw asset reference. |
image_url | string | null | The resolved, servable image URL — render this directly, no separate asset lookup needed. |
is_subscription | boolean | |
subscription_interval | "day" | "week" | "month" | "year" | null | |
subscription_interval_count | number | null | |
subscription_price_cents | number | null | Integer cents. |
variants | StorefrontVariant[] | Each variant has id, sku, label, price_cents, currency. |
Money is integer cents everywhere. variants[].price_cents and
subscription_price_cents are cents, not dollars. Format at the render edge.
store.collections
collections.list(): Promise<{ collections: StorefrontCollection[] }>
collections.get(slug: string): Promise<CollectionResponse>const { collections } = await store.collections.list();
const summer = await store.collections.get("summer-stack");list takes no query parameters (its query type is never). get takes
the collection slug and returns the collection together with its active
products; an unknown slug throws FlightdeckError (404). A StorefrontCollection
is { slug, title, description }.
store.search
Faceted catalog search. Unlike the resource namespaces, search is a callable
on the client itself, and it takes a required query argument (every field
inside that object is optional).
search(query: {
q?: string;
collection?: string;
price_min_cents?: number | null;
price_max_cents?: number | null;
limit?: number;
offset?: number | null;
}): Promise<{
query: string;
result_count: number;
limit: number;
offset: number;
filters: StorefrontSearchFilters;
facets: StorefrontSearchFacets;
products: StorefrontProduct[];
}>const results = await store.search({ q: "peptide", collection: "peptides", limit: 10 });
results.result_count; // total matches
results.facets.collections; // [{ slug, title, count }, …]
results.facets.price_buckets; // [{ min_cents, max_cents, count }, …]An empty or blank q returns an empty, unfiltered result set — it is not an
error.
Price filters do not currently reach the backend through the typed fields.
The generated query type names them price_min_cents / price_max_cents, but
the backend handler reads min_price_cents / max_price_cents. Passing the
typed fields compiles but is silently ignored. See
the price-filter drift for the
low-level workaround.
store.checkout
The money-mutating surface. Every call here takes a required
{ idempotencyKey } options argument, sent as the Idempotency-Key header (the
API returns 400 without it).
checkout.submit(body, { idempotencyKey }) // place an order
checkout.orders.cancel(id, { idempotencyKey }, body?) // cancel a placed order
checkout.orders.edit(id, { idempotencyKey }, body) // edit a placed order
checkout.orders.refund(id, { idempotencyKey }, body) // refund a placed orderMake the idempotencyKey deterministic — derive it from the business event
(cart id + attempt number, order id + action), never a fresh random value per
call. A random key defeats the safety it exists for: a network retry would
place a second order or issue a second refund. A deterministic key lets the API
deduplicate a replay.
checkout.submit(body, opts)
One-shot order placement (tender + capture: store credit, then gift card, then card). The tenant plus the storefront token authorize it.
submit(
body: StorefrontCheckoutRequest,
opts: { idempotencyKey: string },
): Promise<CheckoutResponse>The body (StorefrontCheckoutRequest) key fields:
| Field | Type | Required | Notes |
|---|---|---|---|
customer_ref | string | yes | e.g. guest:jane@example.com. |
items | { variant_id: string; quantity: number }[] | yes | The cart lines. |
card | { ccnumber; ccexp; cvv? } | conditional | Only when a card charge is actually needed (credit + gift card may cover the total). |
apply_store_credit | boolean | no | |
gift_card_code | string | no | |
coupon_code | string | no | |
subscribe | boolean | no | Subscribe-and-save: also start a subscription for each eligible line. |
shipping_address | StorefrontShipTo | no | |
referral_code, email, ip | string | no | |
shipping | { mode: "flat"; flat_rate_cents; expedited_rate_cents; max_cents; free_shipping_threshold_cents? } | no | Fail-closed shipping policy. |
tax | { provider: "null" | "flat_rate" | "stripe_tax"; rate_bps } | no | |
expedited | boolean | no |
const result = await store.checkout.submit(
{
customer_ref: `guest:${email}`,
items: [{ variant_id: variantId, quantity: 1 }],
card: { ccnumber, ccexp, cvv },
},
{ idempotencyKey: `cart-${cartId}:attempt-${attempt}` },
);
if (result.outcome === "placed") {
result.order.order_number; // e.g. 1042
result.card_charged_cents; // the tender split (integer cents)
result.store_credit_applied_cents;
result.gift_card_applied_cents;
}The response is a discriminated union on outcome:
201/outcome: "placed"— a fresh placement. Carriesorder,items,charge_id,intent_id, the tender split (card_charged_cents,store_credit_applied_cents,gift_card_applied_cents), anyissued_gift_cards, anysubscriptions, andreplayed: false.200/outcome: "already_placed"— an idempotent replay of a checkout that already placed under the same key. No re-charge;replayed: true.
checkout.orders — post-placement mutations
Each operates on a placed order by id, requires an idempotencyKey, and
returns its own distinct response shape.
| Method | Signature | Body type | Body required |
|---|---|---|---|
cancel | cancel(id, opts, body?) | StorefrontCancelRequest — { charge_id?, reason?, actor? } | no (all fields optional) |
edit | edit(id, opts, body) | StorefrontEditRequest — { edits: { order_item_id; quantity }[], charge_id?, card?, actor?, shipping?, tax?, expedited? } | yes |
refund | refund(id, opts, body) | StorefrontRefundRequest — { charge_id, amount_cents?, reason? } | yes |
// Full or partial refund. Omit amount_cents for a full refund.
await store.checkout.orders.refund(
orderId,
{ idempotencyKey: `order-${orderId}:refund-1` },
{ charge_id: chargeId, amount_cents: 1500, reason: "return accepted" },
);
// Cancel — body is optional.
await store.checkout.orders.cancel(
orderId,
{ idempotencyKey: `order-${orderId}:cancel` },
);
// Edit line quantities.
await store.checkout.orders.edit(
orderId,
{ idempotencyKey: `order-${orderId}:edit-1` },
{ edits: [{ order_item_id: itemId, quantity: 2 }] },
);store.shopper
A signed-in shopper’s own order history for this tenant.
shopper.orders(query?: { person_id: string }): Promise<{ orders: StorefrontOrder[] }>
shopper.order(id: string): Promise<{ order: StorefrontOrder; items: StorefrontOrderItem[] }>const { orders } = await store.shopper.orders({ person_id });person_id is required by the API even though the SDK marks the argument
optional. shopper.orders() compiles with no argument, but the endpoint
returns 400 without person_id — always pass { person_id }.
A sharper caveat applies to shopper.order(id): the same endpoint also
requires a person_id query parameter, but the SDK method’s signature exposes
no way to supply it (it sends only the path). Until that is fixed, fetch a
single shopper order through the
low-level client,
which lets you pass the person_id query explicitly.
A StorefrontOrder carries id, order_number, customer_ref, person_id,
status (the full order-status enum), the money legs (subtotal_cents,
discount_cents, shipping_cents, tax_cents, total_cents, all integer
cents), currency, idempotency_key, and ship_to.
store.storeCredit
storeCredit.balance(query?: { customer_ref: string }): Promise<{
customer_ref: string;
balance_cents: number;
currency: string;
}>const { balance_cents } = await store.storeCredit.balance({ customer_ref });As with shopper.orders, the argument is typed optional but the API requires
customer_ref — always pass { customer_ref } or the call returns 400. The
balance is integer cents. A failed read is a 503 (FlightdeckError), never a
laundered zero balance — do not treat an error as “no credit”.
Method summary
| Namespace | Method | Query / body | Returns |
|---|---|---|---|
site | get() | none | { tenant_ref, version, manifest } |
products | list() | none | { products } |
products | get(slug) | slug (path) | { product } |
collections | list() | none | { collections } |
collections | get(slug) | slug (path) | collection + products |
| (root) | search(query) | required query object | search result + facets |
checkout | submit(body, {idempotencyKey}) | checkout body | placed / replayed |
checkout.orders | cancel(id, opts, body?) | optional cancel body | cancel result |
checkout.orders | edit(id, opts, body) | required edit body | edit result |
checkout.orders | refund(id, opts, body) | required refund body | refund result |
shopper | orders(query?) | person_id (required in practice) | { orders } |
shopper | order(id) | see caveat above | { order, items } |
storeCredit | balance(query?) | customer_ref (required in practice) | { customer_ref, balance_cents, currency } |
See also
- Error handling — catching
FlightdeckErrorand retrying safely. - TypeScript usage — importing the request and response types by name.
- Merchant / low-level client — calling paths the wrapper does not name.