Batch SDK · v0.1.0
Add real ordering to any website.
Products, capacity-aware pickup and delivery dates, 0% payments and checkout — embed them with one script tag, or pull everything as typed data and render it in your own design. Same engine underneath the hosted storefront, on your domain.
Free to embed · plain HTML or React · TypeScript-first
<script async src="https://js.usebatch.shop/v1.js"
data-batch-shop="your-shop-slug" data-batch-theme="auto"></script>
<batch-menu></batch-menu>
<batch-cart-button></batch-cart-button>Choose your path
Two ways to integrate
The SDK is headless-first. Everything is built on a typed API client and a server-priced cart kernel; the drop-in widgets are a thin, optional layer on top. Both paths are first-class — pick by how much design control you want.
| Path 1 — Widgets | Path 2 — Headless | |
|---|---|---|
| You write | HTML tags / attributes | Your own HTML/CSS/JS (or React) |
| DOM & markup | Batch renders it (fixed structure) | You render everything |
| Theming | --batch-* vars + host CSS | Total — it’s your markup |
| Cart · options · checkout | Automatic (drawer + modal) | You wire the pieces (helpers provided) |
| Best for | Live fast; default look is fine | Bespoke design: custom cards, fonts, layout |
| Entry point | script tag / <BatchProvider> | createClient() + createCartStore() |
Building a custom-designed site? Go headless.
<batch-cart-button> and let the built-in drawer handle checkout).Setup
Install & connect to a shop
A client is bound to a single shop slug (e.g. mayas-cakes in mayas-cakes.usebatch.shop). Everything is scoped to it.
Option A — Script tag
Zero build. The ~2 KB loader injects the immutable, SRI-pinned core and auto-boots.
<script
async
src="https://js.usebatch.shop/v1.js"
data-batch-shop="your-shop-slug"
data-batch-theme="auto"></script>| Attribute | Required | Meaning |
|---|---|---|
data-batch-shop | yes | the shop slug to bind to |
data-batch-theme | no | "auto" derives colors from the shop’s published design; omit for none |
data-batch-api | no | override the API base URL (staging/local) |
Option B — npm
For bundlers, TypeScript and React.
npm install @batch/sdkNot on the public registry yet
@batch/sdk has not been published to npm — the org name and publish token are the last open item. Until it lands, ship with the script tag (Option A) or import the ES module straight off the CDN (Option C); both serve the same bytes and the same typed surface. Building against npm today? Tell us and we’ll publish it.import { createClient, createCartStore, money } from '@batch/sdk';
// React entry:
import { BatchProvider, useBatch, Menu } from '@batch/sdk/react';
// Optional starter theme:
import '@batch/sdk/styles/alphaprism.css';react is an optional peer dependency (^18.2 || ^19) — needed only if you import @batch/sdk/react.
Option C — Headless from the CDN (static HTML, no build)
For a hand-written static site — a single index.html on Cloudflare Pages, Netlify or GitHub Pages — that wants the headless client with no npm and no bundler. Import the SDK straight off the CDN inside a native <script type="module">:
<script type="module">
import { createClient, createCartStore, money }
from 'https://js.usebatch.shop/v1/batch.esm.js';
const client = createClient({ shop: 'your-shop-slug' });
const cart = createCartStore(client);
// …render products, wire your cart + an availability-gated date picker, then checkout…
</script>| URL | Cache | Use when |
|---|---|---|
https://js.usebatch.shop/v1/batch.esm.js | short (5 min) | latest headless SDK — recommended |
https://js.usebatch.shop/v0.1.0/batch.esm.js | immutable (1 yr) | pin an exact version |
Import batch.esm.js — not v1.js or core.js
v1.js and /v{version}/core.js are the Path 1 widget artifacts (an IIFE that boots the <batch-*> elements and sets a global Batch) — they export nothing, so import { createClient } from them yields undefined. The headless module you import from is batch.esm.js, served with Access-Control-Allow-Origin: * so it imports from any origin.Base URL
- Default:
https://usebatch.shop/api/v1(exported asDEFAULT_BASE_URL) — the live production API. You don’t need to set it; a static site on any domain works with justcreateClient({ shop }). - Override per client with
baseUrl(ordata-batch-api) only to point at a staging or local Engine API.
Quickstart
From zero to an order
Path 1 — Widgets
<script async src="https://js.usebatch.shop/v1.js"
data-batch-shop="mayas-cakes" data-batch-theme="auto"></script>
<batch-cart-button></batch-cart-button>
<batch-menu></batch-menu>
<button data-batch-buy="prod_choc_cake">Order the chocolate cake</button>Path 2 — Headless
import { createClient, createCartStore } from '@batch/sdk';
// static HTML, no build? import from 'https://js.usebatch.shop/v1/batch.esm.js' (Option C)
const client = createClient({ shop: 'mayas-cakes' });
const cart = createCartStore(client);
await cart.hydrate(); // load any persisted cart
const products = await client.getProducts();
// …render products in your own markup…
await cart.add({ product_id: products[0].id, qty: 1 });
const { token } = cart.getState();
const session = await client.createCheckoutSession({ cart_token: token! });
// branch on mode — card shops get a Stripe URL, pay-at-pickup shops don't:
location.assign(session.mode === 'stripe' ? session.checkout_url : session.order_status_url);Headless rule of thumb
createClient() + createCartStore() and do not call init() / <BatchProvider>. Those inject the widget stylesheet, mount the built-in cart drawer, and register the <batch-*> elements — the default look you’re replacing. Headless = the client, the cart store, and the helpers; no runtime, no default UI.Path 1 · drop-in
Widgets
Add the script tag, then place any of these. They upgrade automatically once the runtime boots. Most come in two forms: a custom element (<batch-*>) for authored placement, and a declarative attribute (data-batch-*) to upgrade an existing element.
Menu — the product grid
Renders each product as a card. Quote-only products (kind: "custom" or no price) show Request a quote and scroll to the quote form.
<batch-menu></batch-menu>
<batch-menu data-category="cakes"></batch-menu> <!-- filter to one category -->
<div data-batch-menu data-category="cookies"></div> <!-- upgrade your own element -->Buy button
Adds a product to the cart. If the product has configurable options, it opens an accessible option-picker modal first; otherwise it adds and opens the cart drawer. Failures render inline next to the button — never a silent console error.
<batch-buy product="prod_choc_cake" qty="1" label="Add to order"></batch-buy>
<!-- or upgrade any element (e.g. your own styled button) -->
<button data-batch-buy="prod_choc_cake" data-batch-qty="2">Add two</button>| Attribute | Meaning |
|---|---|
product | product id (required) |
qty / data-batch-qty | quantity (default 1) |
label | button text (default “Add to order”) |
Cart button
Shows a live item count and opens the slide-in drawer. The drawer walks the shopper through line edits → a fulfillment & contact step → checkout, collecting everything the API needs before creating a session, then redirects to payment. It reads the shop’s fulfillment mode for you: 'both' puts a pickup/delivery picker at the top, delivery collects a street address, and name, email and phone are always required.
<batch-cart-button label="Your order"></batch-cart-button>Calendar
A month grid of capacity-aware fulfillment dates (sold-out and past days disabled). Picking a day sets the cart’s fulfillment date and dispatches a bubbling batch:date CustomEvent (with detail.date) you can listen for.
<batch-calendar></batch-calendar>Quote form
Collects event date, details, name and email for custom orders, and submits a quote request. On success it swaps in a confirmation. data-product optionally pre-associates a product.
<batch-quote-form data-product="prod_custom_cake"></batch-quote-form>Path 1 · style
Theming widgets
Every widget color and the corner radius reads a --batch-* custom property with a sane fallback. Set them on :root (or any ancestor — they cascade) to re-skin every widget at once.
:root {
--batch-bg: #f7f2ea; /* drawer / modal background (fallback #fffdf8) */
--batch-surface: #fffdf9; /* card / input background (fallback #fff) */
--batch-ink: #191410; /* primary text (fallback #2b2320) */
--batch-ink-soft: #6f6257; /* muted text (fallback #7a6f66) */
--batch-accent: #b4552d; /* buttons, selected states (fallback #b4552d) */
--batch-accent-2: #4c6b54; /* secondary accent */
--batch-on-accent: #fffdf9; /* text on accent (fallback #fff) */
--batch-radius: 1.25rem; /* corner radius (fallback 0.75rem) */
}Three ways to theme, in order of effort:
data-batch-theme="auto"— derives the color + radius vars from the shop’s published storefront design. (Colors and radius only; the host page always owns typography — widgets inherit yourfont.)- Set the vars yourself (above), or start from the AlphaPrism preset:
import '@batch/sdk/styles/alphaprism.css'. - Override the classes. Because widgets are light DOM, your own CSS can target
.batch-card,.batch-btn,.batch-drawer, etc. directly.
Widgets are light DOM — not Shadow DOM
--batch-* variables cascade straight in; that is the theming mechanism, and widget classes use intentionally low specificity so your page wins. So Path 1’s real limit isn’t encapsulation — it’s that the widgets render a fixed DOM structure and copy. When you need a genuinely different structure, that’s Path 2.Path 1 · hooks
Events
The runtime emits typed events. From a script-tag embed, reach them through the context (available after boot):
// script-tag global is `Batch`
const ctx = Batch.getContext();
ctx?.events.on('cart:updated', ({ count, subtotalCents }) => {
document.querySelector('#cart-count')!.textContent = String(count);
});| Event | Payload |
|---|---|
cart:updated | { count: number; subtotalCents: number | null } |
cart:opened | undefined |
quote:submitted | { number: number } |
checkout:started | undefined |
Batch.on(...) shortcut — events live on getContext()?.events (or useBatch()?.events in React). getContext() returns null until the runtime has booted.Path 2 · full control
The headless client & cart
Fetch raw data, render it in your HTML and CSS, and drive commerce with the same server-authoritative kernel the widgets use — without inheriting any of their markup or styles.
createClient(config)
import { createClient, type BatchClient } from '@batch/sdk';
const client: BatchClient = createClient({
shop: 'mayas-cakes', // required — the shop slug
// baseUrl: 'http://localhost:3000/api/v1', // optional — defaults to the production apex
});One typed, guard-validated method per endpoint. No caching — you decide freshness.
| Method | Returns |
|---|---|
getShop() | Promise<PublicShop> |
getDesign() | Promise<PublicDesign> |
getProducts({ category? }) | Promise<PublicProduct[]> |
getAvailability({ from, to, products? }) | Promise<AvailabilityDay[]> |
createCart({ items, fulfillment?, contact? }) | Promise<{ pricing, cart }> |
getCart(token) | Promise<Cart> |
patchCart(token, { items?, fulfillment?, contact? }) | Promise<{ pricing, cart }> |
createCheckoutSession({ cart_token, event_date?, marketing_opt_in? }) | Promise<CheckoutSession> |
requestQuote(input) | Promise<QuoteAck> |
createCart/patchCart/getCart directly — the cart store below wraps them with token persistence, mutation queuing, and cross-tab sync. Reach for the raw methods only for read-only or bespoke flows.Render products your way
getProducts() returns plain data — render it however you like. This is the custom circular-image tile the widgets will never give you:
import { money } from '@batch/sdk';
function productTile(p: PublicProduct, currency: string): string {
const price = p.price_cents !== null
? money(p.price_cents, currency) // integer cents → "$32.00"
: (p.price_note ?? 'Priced by quote');
return `<article class="tile">
<img class="tile__photo" src="${p.images[0] ?? ''}" alt="${p.name}" />
<h3>${p.name}</h3><p>${price}</p>
<button data-product="${p.id}">Add</button>
</article>`;
}
const shop = await client.getShop();
const products = await client.getProducts();
document.querySelector('#menu')!.innerHTML =
products.map((p) => productTile(p, shop.currency)).join('');/* entirely your design — Batch imposes nothing here */
.tile__photo { width: 160px; height: 160px; border-radius: 50%; object-fit: cover; }The cart store
A small observable store over the server cart — a plain function, no init() required. The only thing kept on the page is an opaque token in localStorage; items and pricing live server-side.
import { createCartStore, type CartState } from '@batch/sdk';
const cart = createCartStore(client); // 2nd arg: custom storage (default localStorage)
await cart.hydrate(); // once on boot: load persisted cart
const unsubscribe = cart.subscribe((state: CartState) => renderCart(state));| Method | Notes |
|---|---|
getState() | current CartState snapshot |
subscribe(listener) | returns an unsubscribe fn |
hydrate() | load the persisted cart (once, on boot) |
add(item) | merges into the matching line (same product + selections) |
setQty(productId, selections, qty) | note the arg order; qty <= 0 removes the line |
remove(productId, selections?) | remove a line |
setFulfillment(fulfillment) | e.g. just the pickup date |
setCheckoutDetails(fulfillment, contact) | one PATCH with both — the checkout-completeness write |
clear() · refresh() | reset locally · re-read from the server |
interface CartState {
status: 'idle' | 'loading' | 'ready' | 'error';
token: string | null;
items: CartItem[];
pricing: CartPricing | null; // pricing.subtotal_cents — the server total
fulfillment: Record<string, unknown> | null;
contact: Record<string, unknown> | null;
errorCode: string | null; // stable code when status === 'error'
errorTitle: string | null; // human title — safe to show verbatim
}Money is always the server’s
state.pricing.subtotal_cents for anything that matters. The exported priceHintCents() is a display hint for an option picker only — the server reprices authoritatively on every cart write.Path 2 · commerce logic
Options & pickup dates
Helpers so you don’t reimplement commerce logic. Product options arrive as unknown on the wire — pass them through the guards for typed data.
import {
optionsOf, collectibleOptions, needsConfiguration,
priceHintCents, missingRequired,
} from '@batch/sdk';
const options = optionsOf(product); // ProductOption[] ([] if none/unparseable)
if (needsConfiguration(product)) { // true ⇒ show a picker before adding
const pickable = collectibleOptions(options); // drops 'photos' (no public upload path yet)
// …render your own UI for each option…
}
// live display hint while choosing (NOT the charged price — server reprices):
const hint = priceHintCents(product.price_cents ?? 0, options, selections);
// pre-submit validation, mirroring the server's required checks:
const missing = missingRequired(options, selections); // [{ option_id, label }]ProductOption is a discriminated union on type (select, multi, text, photos); selections are keyed by option id — a select maps to one choice id, a multi to an array of ids, a text to the string.
Availability & a custom calendar (disable closed/full/past days)
A custom date picker must gate on availability, or a shopper can pick a closed/past/fully-booked day and hit a 422 at checkout. A day is selectable iff day.open === true — the Engine folds closed weekdays, past/lead-time dates, and sold-out days all into that one flag. selectableDates() gives you the set of pickable dates; disable every cell not in it.
import { monthGrid, selectableDates, windowsOf, windowDisplay } from '@batch/sdk';
const grid = monthGrid(2026, 8); // { label:"August 2026", cells:[42], from, to }
const days = await client.getAvailability({ from: grid.from, to: grid.to });
const open = selectableDates(days); // Set<'YYYY-MM-DD'> — the pickable days
for (const cell of grid.cells) { // 'YYYY-MM-DD' | null (padding)
if (!cell) continue;
const selectable = open.has(cell); // closed/full/past are simply absent
// …render a day button; add `disabled` when !selectable…
}
// pickup windows — read via windowsOf(), never .windows directly:
const byDate = new Map(days.map((d) => [d.date, d]));
for (const w of windowsOf(byDate.get('2026-08-01'))) {
const label = w.label; // ← submit this as fulfillment.window
const text = windowDisplay(w); // "Morning (9:00–11:00)" for the UI
}Never trust a bare <input type="date">— it can’t express “only these days.” nextMonth(y, m) / prevMonth(y, m) return [year, month] for pager buttons.
Fulfillment & contact keys
Before checkout, the cart needs a capacity-validated date and contact. These go in as loosely-typed records; the keys the Engine API expects (matching what the widgets send):
await cart.setCheckoutDetails(
{ method: 'pickup', on_date: '2026-08-01', window: 'Morning (9–11am)' },
{ name: 'Dana R.', email: 'dana@example.com', phone: '(503) 555-0148' },
);The window value must be the window’s label (the server matches on it).
Phone is required — and delivery needs an address
contact.phone is mandatory on every checkout; a cart without it fails validation at createCheckoutSession. Validation is deliberately lenient — anything with seven or more digits after stripping formatting passes, so a determined buyer is never blocked over punctuation. Read shop.fulfillment.mode ('pickup' · 'delivery' · 'both') to know which methods the shop allows — the server refuses any method it doesn’t offer, and refuses a delivery with no address.await cart.setCheckoutDetails(
{
method: 'delivery',
on_date: '2026-08-01',
address: { street: '1412 SE Morrison St', city: 'Portland', zip: '97214' },
},
{ name: 'Dana R.', email: 'dana@example.com', phone: '(503) 555-0148' },
);shop.fulfillment.pickup_note / delivery_notecarry the merchant’s own instruction copy for each mode — render them next to your date picker. There are no delivery fees or zones in v1: the note is the merchant’s tool.
Path 2 · the money shot
A complete custom storefront
One self-contained module: fetch → render custom cards → add (with an option modal when needed) → live cart → an availability-gated pickup date picker → checkout that works for card and pay-at-pickup shops alike. No widgets, no default styles — your markup throughout. It compiles against the current SDK types.
import {
createClient, createCartStore,
optionsOf, collectibleOptions, missingRequired, priceHintCents, money,
monthGrid, selectableDates, nextMonth, prevMonth,
type CartState, type OptionSelections, type ProductOption,
type PublicProduct, type PublicShop,
} from '@batch/sdk';
const client = createClient({ shop: 'mayas-cakes' });
const cart = createCartStore(client);
let shop: PublicShop;
let catalog: PublicProduct[] = [];
const $ = <T extends HTMLElement>(sel: string) => document.querySelector<T>(sel)!;
async function boot() {
[shop, catalog] = await Promise.all([client.getShop(), client.getProducts()]);
renderMenu();
cart.subscribe(renderCart);
await Promise.all([cart.hydrate(), renderDatePicker()]);
}
// ── custom product grid ──────────────────────────────────────────────
function renderMenu() {
$('#menu').innerHTML = catalog.map((p) => {
const price = p.price_cents !== null
? money(p.price_cents, shop.currency) : (p.price_note ?? 'By quote');
return `<article class="tile" data-id="${p.id}">
<img class="tile__img" src="${p.images[0] ?? ''}" alt="${p.name}" />
<h3>${p.name}</h3><p class="tile__price">${price}</p>
<button class="tile__add" data-id="${p.id}">Add to order</button>
</article>`;
}).join('');
}
$('#menu').addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.tile__add');
if (!btn) return;
const product = catalog.find((p) => p.id === btn.dataset.id);
if (!product) return;
if (collectibleOptions(optionsOf(product)).length > 0) openOptionModal(product);
else void addToCart(product.id, {});
});
async function addToCart(productId: string, selections: OptionSelections) {
await cart.add({ product_id: productId, qty: 1, selections });
const s = cart.getState();
if (s.status === 'error') alert(s.errorTitle ?? 'Could not add — try again.');
}
// ── custom option modal ──────────────────────────────────────────────
function openOptionModal(product: PublicProduct) {
const options = collectibleOptions(optionsOf(product));
const selections: OptionSelections = {};
const modal = $('#option-modal');
modal.hidden = false;
const paint = () => {
const hint = priceHintCents(product.price_cents ?? 0, options, selections);
$('#option-price').textContent = money(hint, shop.currency);
};
$('#option-body').innerHTML = options.map(optionMarkup).join('');
$('#option-body').onclick = (e) => {
const chip = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-opt]');
if (!chip) return;
const { opt, choice, kind } = chip.dataset;
if (kind === 'select') selections[opt!] = choice!;
else {
const arr = Array.isArray(selections[opt!]) ? (selections[opt!] as string[]) : [];
selections[opt!] = arr.includes(choice!) ? arr.filter((c) => c !== choice) : [...arr, choice!];
}
paint();
};
$<HTMLButtonElement>('#option-confirm').onclick = async () => {
const missing = missingRequired(options, selections);
if (missing.length) {
$('#option-error').textContent = `Choose ${missing.map((m) => m.label).join(', ')}.`;
return;
}
modal.hidden = true;
await addToCart(product.id, selections);
};
paint();
}
function optionMarkup(o: ProductOption): string {
if (o.type === 'text' || o.type === 'photos') return '';
const chips = o.choices.map((c) =>
`<button data-opt="${o.id}" data-choice="${c.id}" data-kind="${o.type}">
${c.label}${c.price_delta_cents ? ` +${money(c.price_delta_cents, shop.currency)}` : ''}
</button>`).join('');
return `<fieldset><legend>${o.label}</legend>${chips}</fieldset>`;
}
// ── your cart UI ─────────────────────────────────────────────────────
function renderCart(state: CartState) {
$('#cart-count').textContent = String(state.items.reduce((n, i) => n + i.qty, 0));
$('#cart-subtotal').textContent = state.pricing
? money(state.pricing.subtotal_cents, shop.currency) : '—';
$<HTMLButtonElement>('#checkout').disabled =
state.items.length === 0 || state.status === 'loading';
}
// ── availability-gated pickup date picker (never offers a closed day) ─
const now = new Date();
let calMonth: [number, number] = [now.getFullYear(), now.getMonth() + 1];
let pickedDate: string | null = null;
async function renderDatePicker() {
const grid = monthGrid(...calMonth);
const days = await client.getAvailability({ from: grid.from, to: grid.to });
const open = selectableDates(days); // Set<'YYYY-MM-DD'>
$('#cal-label').textContent = grid.label;
$('#cal-grid').innerHTML = grid.cells.map((cell) => {
if (!cell) return `<span class="cal__pad"></span>`;
const on = open.has(cell); // closed/full/past are absent
return `<button type="button" class="cal__day" data-date="${cell}"
${on ? '' : 'disabled aria-disabled="true"'}
${cell === pickedDate ? 'aria-pressed="true"' : ''}>${Number(cell.slice(-2))}</button>`;
}).join('');
}
$('#cal-grid').addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.cal__day');
if (!btn || btn.disabled) return; // closed days can't be chosen
pickedDate = btn.dataset.date!;
void renderDatePicker(); // reflect the selection
});
$('#cal-prev').addEventListener('click', () => { calMonth = prevMonth(...calMonth); void renderDatePicker(); });
$('#cal-next').addEventListener('click', () => { calMonth = nextMonth(...calMonth); void renderDatePicker(); });
// ── checkout: details → card OR pay-at-pickup ────────────────────────
$('#checkout').addEventListener('click', async () => {
const name = $<HTMLInputElement>('#name').value.trim();
const email = $<HTMLInputElement>('#email').value.trim();
const phone = $<HTMLInputElement>('#phone').value.trim();
// phone is REQUIRED by the API; be as lenient as the server is (≥7 digits)
if (!pickedDate || !name || !email.includes('@') || phone.replace(/\D/g, '').length < 7) {
$('#checkout-error').textContent = 'Pick an available date, your name, email, and a phone number.';
return;
}
await cart.setCheckoutDetails({ method: 'pickup', on_date: pickedDate }, { name, email, phone });
const s = cart.getState();
if (s.status === 'error') {
// a picked date can't 422 here — but a slot can sell out between pick and checkout
$('#checkout-error').textContent = s.errorTitle ?? 'That date just filled up — pick another.';
void renderDatePicker();
return;
}
const session = await client.createCheckoutSession({ cart_token: s.token! });
// card shop → Stripe; pay-at-pickup shop → hosted status page with instructions
location.assign(session.mode === 'stripe' ? session.checkout_url : session.order_status_url);
});
void boot();That’s the whole commerce loop — every total, capacity check and price comes from the server; your markup and CSS own everything the shopper sees.
Path 2 · checkout
The payment flow
Once the cart has items, a valid fulfillment date and contact, create a session and redirect.CheckoutSession is a union — always branch on mode.
const session = await client.createCheckoutSession({ cart_token: state.token! });
if (session.mode === 'stripe') {
location.assign(session.checkout_url); // Stripe-hosted checkout
} else {
location.assign(session.order_status_url); // manual-payment shop → instructions page
}
// type CheckoutSession =
// | { mode: 'stripe'; checkout_url; order_status_url; order_number }
// | { mode: 'manual'; order_status_url; order_number }- Checkout is server-hosted. You never handle card data — you redirect to Stripe’s hosted checkout. PCI stays SAQ-A even on a random agency site.
- Nothing is charged until Stripe completes. Creating the session places a
pending_paymentorder that holds capacity briefly; a sweeper releases it on abandonment. - Manual-payment shops have no
checkout_url— send the shopper toorder_status_url(the merchant’s payment instructions). - Direct charges, 0% platform fee — money settles to the merchant’s own Stripe account; Batch takes nothing.
Reference
React
@batch/sdk/react provides a provider, a hook, and thin component wrappers over the same <batch-*> elements — plain-HTML and React embeds behave identically.
import { BatchProvider, useBatch, CartButton, Menu, BuyButton } from '@batch/sdk/react';
function App() {
return (
<BatchProvider shop="mayas-cakes" theme="auto">
<CartButton label="Your order" />
<Menu category="cakes" />
<BuyButton product="prod_choc_cake" qty={1} label="Add" />
</BatchProvider>
);
}BatchProvider takes the same options as init() (shop, baseUrl?, theme?, root?) plus children, and boots the runtime once (StrictMode-safe). useBatch() returns the BatchContext (null until booted).
| Component | Props |
|---|---|
<BuyButton> | product; qty?; label?; className? |
<CartButton> | label?; className? |
<Menu> | category?; className? |
<QuoteForm> | product?; className? |
<Calendar> | onDate?(date); className? |
Headless in React
For a fully custom React UI, skip the wrappers and use the client + cart store directly with useSyncExternalStore — zero default widget markup or styles (the agency-site default):
import { useMemo, useSyncExternalStore } from 'react';
import { createClient, createCartStore } from '@batch/sdk';
function useCart(shop: string) {
const { client, cart } = useMemo(() => {
const client = createClient({ shop });
return { client, cart: createCartStore(client) };
}, [shop]);
const state = useSyncExternalStore(cart.subscribe, cart.getState, cart.getState);
return { client, cart, state };
}Reference
Error handling
Two error species, both exported from the package root:
class BatchApiError extends Error {
code: string; // stable problem code, e.g. 'capacity_gone', 'option_required'
status: number; // HTTP status
title: string; // human-readable; safe to show verbatim
}
class BatchNetworkError extends Error {
cause?: unknown; // fetch failed, timed out, or the body was empty/non-JSON
}BatchApiError means the API returned a problem+json (any non-2xx), or a 2xx body failed its shape guard (invalid_response). BatchNetworkError means the request never got a valid JSON answer (offline, DNS, CORS, timeout). Requests time out at 15 s; there are no retries.
With the cart store you rarely try/catch — failures surface in state:
const s = cart.getState();
if (s.status === 'error') {
// s.errorCode: stable code · s.errorTitle: show this to the shopper
showBanner(s.errorTitle ?? 'Something went wrong — try again.');
}404 on the cart (expired or checked-out) auto-resets to an empty cart — not an error you handle. For total outages, getShop() exposes the shop’s instagram_handle as a graceful DM-to-order fallback, exactly as the widgets do.Reference
Types & exports
The types you’ll touch most, all exported from @batch/sdk:
interface PublicShop {
id: string; slug: string; name: string; bio: string | null;
currency: string; timezone: string; city: string | null;
instagram_handle: string | null;
my_orders_url?: string; // the shop's passwordless order-lookup page
fulfillment?: { // absent on older servers = pickup only
mode: 'pickup' | 'delivery' | 'both';
pickup_note?: string | null; delivery_note?: string | null;
};
}
// what setCheckoutDetails() sends — phone is REQUIRED, address only on delivery
interface CheckoutFulfillment {
method: 'pickup' | 'delivery'; on_date: string; window?: string;
address?: { street: string; city: string; zip: string };
}
interface CheckoutContact { name: string; email: string; phone: string; }
interface PublicProduct {
id: string; kind: string; name: string; description: string | null;
price_cents: number | null; price_note: string | null; category: string | null;
images: string[]; options: unknown; // ← read via optionsOf(product)
}
type ProductOption =
| { id; label; type: 'select'; required: boolean; choices: OptionChoice[] }
| { id; label; type: 'multi'; max?: number; choices: OptionChoice[] }
| { id; label; type: 'text'; required: boolean; max_len?: number }
| { id; label; type: 'photos'; max?: number };
type OptionSelections = Record<string, string | string[]>;
interface AvailabilityDay {
date: string; open: boolean; remaining_orders: number | null;
windows: unknown[]; // ← read via windowsOf(day)
products?: Record<string, number>;
}
interface CartItem { product_id: string; qty: number; selections?: OptionSelections; }
interface CartPricing { subtotal_cents: number; [key: string]: unknown; }
type CheckoutSession =
| { mode: 'stripe'; checkout_url: string; order_status_url: string; order_number: number }
| { mode: 'manual'; order_status_url: string; order_number: number };
interface QuoteRequest {
product_id?: string; event_date: string; details: string;
fulfillment_method?: 'pickup' | 'delivery';
contact: { name: string; email: string; phone?: string }; marketing_opt_in?: boolean;
}Full export list
createClient, DEFAULT_BASE_URL, type BatchClient
createCartStore, mergeLine, sameLine, type CartState, CartStatus, CartStore, KeyValueStorage
init, bootFromScriptTag, getContext, cartCount, type BatchContext, BatchEvents, BatchInitOptions
createEmitter, type Emitter
optionsOf, collectibleOptions, needsConfiguration, priceHintCents, missingRequired,
windowsOf, windowDisplay, type SelectionIssue
themeVarsFromDesign, applyThemeVars
money, dateLabel
monthGrid, nextMonth, prevMonth, isoOf, selectableDates, type MonthGrid
BASE_CSS, injectStyles, STYLE_ELEMENT_ID
BatchApiError, BatchNetworkError, SDK_VERSION
types: AvailabilityDay, BatchClientConfig, Cart, CartItem, CartPricing, CheckoutSession,
OptionChoice, OptionSelections, ProductOption, PublicDesign, PublicProduct, PublicShop,
QuoteAck, QuoteContact, QuoteRequestBatchProvider, useBatch, BuyButton, CartButton, Menu, QuoteForm, CalendarReference
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
<batch-*> | Nothing renders → the runtime hasn’t booted. Ensure data-batch-shop is set, or call init() / mount <BatchProvider> once. |
| Widgets appear unstyled | You rendered <batch-*> without booting, so the stylesheet wasn’t injected. For headless you want no styles — render your own markup. |
getProducts() is empty | Wrong shop slug, wrong baseUrl, or the shop isn’t published. Verify with getShop() — a bad slug 404s. |
| CORS / network errors | baseUrl points at a host that doesn’t allow your origin. Use the default in production. The symptom is BatchNetworkError. |
| Checkout does nothing | Set a valid fulfillment.on_date + contact via setCheckoutDetails before createCheckoutSession, with ≥1 item — then branch on session.mode. |
| Totals look wrong | You’re computing them. Render state.pricing.subtotal_cents; priceHintCents() is a picker hint only. |
| Node/SSR import crashes | Importing @batch/sdk is Node-safe; only init() in a real document evaluates widget classes. Don’t call init() server-side. |
Source of truth
docs/SDK_GUIDE.md and the SDK at v0.1.0. If a signature here ever disagrees with the package source, the source wins.