SDKStorefront client

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

OptionTypeRequiredNotes
apiUrlstringyesThe commerce API origin, e.g. https://api.ruo.pro. A trailing slash is trimmed.
tenantstringyesYour store’s tenant_ref. Injected into the {tenant} segment of every call. Never read from request data (fail-closed tenancy).
storefrontTokenstringyesSent as the x-storefront-token header. Checkout-authorizing and platform-wide today, not per-tenant. Server-side only.
fetchtypeof fetchnoInjectable 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-only

store.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 }>
ParameterTypeRequiredNotes
(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 list

The manifest is a SiteManifeststore, 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:

FieldTypeNotes
id, tenant_ref, slug, namestringIdentity.
descriptionstring | null
status"draft" | "active" | "archived"Storefront lists return active.
image_asset_refstring | nullThe raw asset reference.
image_urlstring | nullThe resolved, servable image URL — render this directly, no separate asset lookup needed.
is_subscriptionboolean
subscription_interval"day" | "week" | "month" | "year" | null
subscription_interval_countnumber | null
subscription_price_centsnumber | nullInteger cents.
variantsStorefrontVariant[]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 order
⚠️

Make 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:

FieldTypeRequiredNotes
customer_refstringyese.g. guest:jane@example.com.
items{ variant_id: string; quantity: number }[]yesThe cart lines.
card{ ccnumber; ccexp; cvv? }conditionalOnly when a card charge is actually needed (credit + gift card may cover the total).
apply_store_creditbooleanno
gift_card_codestringno
coupon_codestringno
subscribebooleannoSubscribe-and-save: also start a subscription for each eligible line.
shipping_addressStorefrontShipTono
referral_code, email, ipstringno
shipping{ mode: "flat"; flat_rate_cents; expedited_rate_cents; max_cents; free_shipping_threshold_cents? }noFail-closed shipping policy.
tax{ provider: "null" | "flat_rate" | "stripe_tax"; rate_bps }no
expeditedbooleanno
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. Carries order, items, charge_id, intent_id, the tender split (card_charged_cents, store_credit_applied_cents, gift_card_applied_cents), any issued_gift_cards, any subscriptions, and replayed: 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.

MethodSignatureBody typeBody required
cancelcancel(id, opts, body?)StorefrontCancelRequest{ charge_id?, reason?, actor? }no (all fields optional)
editedit(id, opts, body)StorefrontEditRequest{ edits: { order_item_id; quantity }[], charge_id?, card?, actor?, shipping?, tax?, expedited? }yes
refundrefund(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

NamespaceMethodQuery / bodyReturns
siteget()none{ tenant_ref, version, manifest }
productslist()none{ products }
productsget(slug)slug (path){ product }
collectionslist()none{ collections }
collectionsget(slug)slug (path)collection + products
(root)search(query)required query objectsearch result + facets
checkoutsubmit(body, {idempotencyKey})checkout bodyplaced / replayed
checkout.orderscancel(id, opts, body?)optional cancel bodycancel result
checkout.ordersedit(id, opts, body)required edit bodyedit result
checkout.ordersrefund(id, opts, body)required refund bodyrefund result
shopperorders(query?)person_id (required in practice){ orders }
shopperorder(id)see caveat above{ order, items }
storeCreditbalance(query?)customer_ref (required in practice){ customer_ref, balance_cents, currency }

See also