@bowlos/reservations
v0.24.0
Published
BowlOS Reservations SDK — headless client + React hooks for the Module 14 public API gateway. White-label booking, account, and flash-sale reads for third-party-hosted center sites.
Maintainers
Readme
@bowlos/reservations
Headless client + React hooks for the BowlOS public API gateway — white-label booking, customer accounts, and flash-sale reads for third-party-hosted center websites. Built for React/Next sites (including AI-agent-built ones) that book on a BowlOS operator's behalf, with Stripe Connect payments landing on the operator's own account.
- Headless-first. A typed client + hooks; you own the UI. The booking engine is meant to match your site's design, not look like a themed plugin — so the primary path is the hooks. → HEADLESS.md maps every built-in booking behavior to its hook + pattern (and how to adopt each SDK release). The drop-in components below are a reference, not the product.
- Two entry points.
@bowlos/reservations(React-free core) and@bowlos/reservations/react(provider + hooks). React is an optional peer. - Dual ESM + CJS, fully typed.
Quickest start: scaffold a site
npx @bowlos/reservations init my-center-siteScaffolds a complete, runnable Next.js center site — /book, /account, and
/flash/[slug] already wired, with the Stripe Connect payment handoff and a
themeable provider. It ships both build styles as a working reference:
/book is built from the headless hooks; /account and /flash use the
themeable components. Keep what fits, swap the rest. Then:
cd my-center-site && npm install
cp env.example .env.local # fill in your key + brand/center/product ids
npm run devPrefer to wire it by hand, or add the SDK to an existing app? Read on.
Install
npm install @bowlos/reservationsReact is a peer dependency (only needed for the /react entry):
npm install reactConfigure
You need a brand API key (minted by the operator) and the gateway base URL. The key is publishable — it's gated by a per-key origin allow-list, so it's safe in browser code (like a Stripe publishable key). Add your site's origin to the key's allow-list.
import { createBowlosClient, localStorageTokenStore } from "@bowlos/reservations";
export const bowlos = createBowlosClient({
apiKey: process.env.NEXT_PUBLIC_BOWLOS_API_KEY!,
baseUrl: "https://<deployment>.convex.site/api/public/v1",
tokenStore: localStorageTokenStore(), // persists the customer session
});Verify your setup
A built-in doctor checks reachability, the API key, origin allow-listing, and the data plane:
npx bowlos doctor --api-key "$NEXT_PUBLIC_BOWLOS_API_KEY" --base-url "https://<deployment>.convex.site/api/public/v1"Quick start (React)
Wrap your app in the provider, then use the typed hooks:
import { BowlosProvider } from "@bowlos/reservations/react";
import { bowlos } from "./bowlos";
export function Providers({ children }: { children: React.ReactNode }) {
return <BowlosProvider client={bowlos}>{children}</BowlosProvider>;
}import { useAvailability } from "@bowlos/reservations/react";
function LaneAvailability() {
const availability = useAvailability({
productId: "<product_id>",
dateISO: "2026-07-04",
startMinuteOfDay: 18 * 60,
endMinuteOfDay: 19 * 60,
});
if (availability.loading) return <p>Checking…</p>;
return <p>{availability.data?.busyUnitIds.length ?? 0} units busy</p>;
}Booking → pay → confirm
useBookingFlow orchestrates the hold → payment → confirmation state machine.
It hands you the Stripe Connect PaymentIntent (clientSecret +
connectedAccountId) — it does not load Stripe.js, so you render Stripe Elements
yourself, scoped to the operator's connected account:
import { useBookingFlow } from "@bowlos/reservations/react";
const flow = useBookingFlow();
// flow.startHold(args) → "held"
// flow.preparePayment() → "awaitingPayment" (+ flow.paymentIntent)
// render Stripe Elements: loadStripe(pk, { stripeAccount: flow.paymentIntent.connectedAccountId })
// flow.startPolling() → "confirmed"A complete reference site (Next.js App Router) using this flow lives in the
BowlOS repo under examples/center-site.
Buying passes, packages & memberships
Direct purchases — game passes, packages, and memberships — use usePurchase, the
buy-flow twin of useBookingFlow: mint a connected-account PaymentIntent → pay with
<BowlosPaymentForm> → poll to materialization. They require a signed-in customer
and charge the operator's connected account.
import { useGameCardPurchase, usePackagePurchase, useMembershipPurchase } from "@bowlos/reservations/react";
const buy = useGameCardPurchase(); // or usePackagePurchase() / useMembershipPurchase()
// buy.start(args) → "awaitingPayment" (+ buy.paymentIntent)
// <BowlosPaymentForm {...buy.paymentIntent} onPaid={() => buy.startPolling()} />
// buy.status === "confirmed" → buy.resultId (the new card / order / membership id)A game pass is one-tap ({ templateId }) — show an itemized Review with
useGameCardPreview (base, tax, booking fee, total) before paying, since the
customer pays more than the bare priceCents. A package carries a config —
Center, date, start time, quantity, add-ons — which you read with usePackage +
useCenterHours, then show an itemized Review with usePackagePreview (base,
add-ons, tax, booking fee, total). A one-time membership is one-tap like a game
pass (useMembershipPurchase, Review with useMembershipPreview — dues + the
one-time setup fee + tax + booking fee). A recurring membership uses
useMembershipSubscriptionCheckout instead: its Review (useMembershipSubscriptionPreview)
shows Due today and Auto-renews at, and there's no poll — it
materializes via webhook, so re-query useMyMemberships after payment. All previews
are server-computed and never diverge from the charge. The full recipes (including
the package start-time slot snap and the recurring no-poll note) are in
HEADLESS.md → "Buying a game pass" / "Buying a package" / "Buying
a one-time membership" / "Buying a recurring membership".
Account + flash sales
Signed-in reads (require a customer session): useMyReservations,
useMyMemberships, useMyLoyalty, useMyCredits, useMyPasses,
useMyGameCards, plus details.
Session-aware flash sales: useFlashSale, useFlashSaleBookingOptions. Manage
the session with useAccount (signIn / signUp / link / signOut).
Self-serve cancel (v0.23.0): preview → confirm → cancel for reservations,
event orders, package orders, and game cards — e.g. useGameCardCancelPreview
useCancelMyGameCard. Returns store credit per the Center's policy (card refunds stay staff-only); game cards cancel only while fully unused. See the "Self-serve cancel" section ofHEADLESS.md.
Every read also has a framework-agnostic function in the core entry
(checkAvailability, bookingPreview, getMyReservations, runDoctor, …) for
non-React or server use.
Drop-in components (@bowlos/reservations/components)
These are a reference / fast-start, not the delivered product. Center sites are headless-first (your design, not a themed plugin) — build from the hooks per HEADLESS.md. Use these to prototype or to read how a behavior is wired.
Want a head start? A themeable, white-label component layer sits on top of the hooks:
import { BowlosProvider } from "@bowlos/reservations/react";
import { BookingFlow, BowlosTheme } from "@bowlos/reservations/components";
<BowlosProvider client={bowlos}>
<BowlosTheme theme={{ colorPrimary: "#e4002b", radius: "8px" }}>
<BookingFlow
centerId="center_…"
productId="product_…"
pricingMode="per_person_per_hour" // count = Bowlers; per_unit_per_hour → Lanes; per_game → Games
unitLabel={{ singular: "lane", plural: "lanes" }} // inventory noun on the "N available" line
renderPayment={({ paymentIntent, onPaid, onError }) => (
<YourStripeForm paymentIntent={paymentIntent} onPaid={onPaid} onError={onError} />
)}
onConfirmed={(r) => console.log("booked", r.reservationDisplayId)}
/>
</BowlosTheme>
</BowlosProvider>;<BookingCatalog brandId centerId onSelect> is the catalog landing — "what
would you like to book?" Since v0.22.0 it renders the canonical
visibility-aware catalog (useCatalog / listPublicCatalog) — the same read
the white-label web and mobile surfaces use — so the Center admin's per-surface
Public Catalog toggles apply to the drop-in too. Each pick hands off a
CatalogItem via onSelect; route by item.action.type with the IDs in
item.action.payload (same routing table as a headless site — HEADLESS.md →
"The canonical catalog").
Date-first booking (v0.24.0): the catalog read now returns the Center's
bookingEntryDefault (+ Center-local todayISO). <BookingCatalog> opens on
that default and shows a "browse by activity / by date" toggle the customer can
flip (showEntryToggle, or force with defaultEntry). Date-first lists what's
bookable on a chosen day (per-product useStartTimes), an Events section, and an
"Available any day" strip. Headless sites build the same from useCatalog +
useStartTimes — see HEADLESS.md → "Date-first booking".
import { BookingCatalog } from "@bowlos/reservations/components";
<BookingCatalog
brandId="brand_…"
centerId="center_…"
onSelect={(item) => {
const p = item.action.payload;
switch (item.action.type) {
case "book_reservation": return router.push(`/book?productId=${p.productId}`);
case "view_event":
case "buy_ticket": return router.push(`/events/${p.slug}`);
case "buy_package": return router.push(`/buy/package/${p.packageId}`);
case "buy_membership": return router.push(`/buy/membership/${p.templateId}`);
case "buy_game_pass": return router.push(`/buy/game-pass/${p.templateId}`);
}
}}
/>;Breaking (v0.22.0):
onSelectpreviously received a per-kindCatalogOfferunion (o.kind/o.id); it now receives aCatalogItem. Section headings come from the backend (override withsectionLabels), and hidden categories/offers no longer render. Headless sites use the same data viauseCatalog({ brandId, centerId, surface })/listCatalog.
The drop-in surfaces cover a center site end to end:
<BookingFlow> runs the whole funnel (availability + price → review → pay
→ confirm). Online booking requires login — there is no anonymous path.
A signed-out visitor sees a sign-in / create-account form (the built-in
<AuthForm>); availability and pricing stay hidden until they authenticate,
then the flow books as them (member rates apply). Anonymous booking is the
walk-in / staff path only, never the public retail surface. It's
pay-to-confirm by default (completeSaleOnly): the inventory hold advances
straight to payment, so the customer never sees a held-but-unpaid reservation.
It hands you the Stripe Connect PaymentIntent via renderPayment. Drop in
the SDK's <BowlosPaymentForm> (see below) and you're done — or render your
own Elements (loadStripe(pk, { stripeAccount: paymentIntent.connectedAccountId
}) → confirmPayment → call onPaid()) for full control.
The date field defaults to today. The start time is a dropdown of real
bookable slots — not a free-form clock — listing only the 30-min start times
that fall within the center's operating hours, clear the booking buffer, and have
an actual price plan (the same pricing engine the quote uses decides, so the
dropdown never offers a slot the booking would reject). A closed or fully-gated
date shows "No times available" and disables booking. The count field labels
itself from pricingMode — Lanes (per_unit_per_hour), Bowlers
(per_person_per_hour), Games (per_game) — and is sent to the API as the
matching field (units / persons / games), so a per-bowler product books
bowlers, not lanes. The page auto-scrolls to the top as the flow advances
past the form.
The primary button reads "Review order" — submitting the form opens an
order-summary review (the reservation + each add-on + the authoritative total)
before anything is held or charged, matching the branded portal's select →
review → pay flow. "Continue to payment" then places the hold and advances to
payment; "Edit order" returns to the form. (Override copy via
labels={{ reviewOrder, reviewHeading, orderTotal, editOrder, … }}.)
Add-ons (shoe rental, upgrades) render automatically: any add-ons configured
on the product appear as a quantity-per-item list, each showing its unit price
and rate (/ person, / unit, or flat). The customer's picks flow into both
the live quote and the booking, so the price updates as add-ons are chosen
and the server re-validates them against the live catalog. Nothing to wire — the
component reads them from the product. (Need them headless? useAddons({
productId }) / listAddons(client, { productId }) return the typed
PublicAddon[]; pass selections back as addons: [{ addonId, quantity }] on the
preview + booking.)
<AccountDashboard brandId> is a complete account page: it signs the
customer in (via the built-in <AuthGate>), then shows their reservations,
memberships, loyalty, account credit, and passes. Pick a subset with
sections={["reservations", "credits"]}.
import { AccountDashboard } from "@bowlos/reservations/components";
<AccountDashboard brandId="brand_…" />;<FlashSale slug> is the branded, login-gated flash-sale page. Anonymous
visitors see the brand, a live countdown, the operator's pre-login message, and
item names with prices locked behind a sign-in form — sale prices are
member-only and never cross the wire until a customer of the Brand signs in.
Once signed in, prices unlock (shown as a per-date range) and each item gets a
Book button you hand off via onBook:
import { FlashSale } from "@bowlos/reservations/components";
<FlashSale slug="tuesday-blowout" onBook={(item) => router.push(`/book?flashItem=${item.id}`)} />;<FlashSaleIndex brandId> is the flash-sale landing page — the brand's
live sales + upcoming scheduled ones (name + when purchases open), or a
"check back soon" notice when there are none. No prices (those stay member-gated
per sale). Open a live sale to route to its <FlashSale> page:
import { FlashSaleIndex } from "@bowlos/reservations/components";
<FlashSaleIndex brandId="brand_…" onOpen={(sale) => router.push(`/flash/${sale.slug}`)} />;<AuthGate> (sign in / create account / finish profile) and <AccountMenu>
(a header login indicator — the customer's first name + sign-out when signed in,
a "Sign in" link otherwise) are exported on their own too. The customer session
is shared app-wide by <BowlosProvider>, so useAccount() returns the same
session everywhere — signing in or out on one page updates the header and the
booking form on every page at once.
Payments (@bowlos/reservations/payments)
<BowlosPaymentForm> is the drop-in Stripe Connect checkout — wire it
into <BookingFlow>'s renderPayment slot and you never hand-build a Stripe
Elements form per site:
import { BookingFlow } from "@bowlos/reservations/components";
import { BowlosPaymentForm } from "@bowlos/reservations/payments";
<BookingFlow
centerId="center_…"
productId="product_…"
renderPayment={(ctx) => (
<BowlosPaymentForm {...ctx} publishableKey={process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!} />
)}
/>;It loads Stripe.js with your platform publishable key scoped to the
operator's connected account (stripeAccount: connectedAccountId), renders
the Payment Element, and confirms the PaymentIntent — the charge lands on the
operator's Stripe account (their name on the statement/receipt). Saved cards +
"save for future" are on by default (via the booking's customer session); pass
saveCard={false} for a one-off charge. {...ctx} forwards the
paymentIntent, amountCents, onPaid, and onError that renderPayment
hands you. Theme the Payment Element to your site with the appearance prop
(appearance={{ theme: "night", variables: { colorPrimary: "#FFD500" } }}) —
Stripe's default is light.
This is the only entry that touches Stripe.js, so @stripe/stripe-js +
@stripe/react-stripe-js are optional peers — install them only if you use
this component; the core / React / components bundles never pull Stripe:
npm install @stripe/stripe-js @stripe/react-stripe-jsPrefer full control? Skip this and render your own Elements in renderPayment.
Theming: neutral defaults with no BowlOS branding (these are your
center's pages). Restyle via <BowlosTheme> or by setting the --bowlos-* CSS
variables yourself (--bowlos-color-primary, --bowlos-radius, --bowlos-font, …).
Errors
Failed calls throw a typed BowlosApiError with a stable .code — branch on
that, never on message text:
import { BowlosApiError } from "@bowlos/reservations";
try {
await bowlos.signIn(email, password);
} catch (e) {
if (e instanceof BowlosApiError && e.code === "INVALID_CREDENTIALS") {
// show "wrong email or password"
}
}License
MIT
