Everything a developer needs to add bookings to a storefront. Follow the real call sequence from start to finish. Read a product's booking setup, check live availability, and hold a slot in the cart that renews while the customer shops. Then place the cart and confirm the reservation when payment clears, whether that's instantly or days later by bank transfer. A separate admin endpoint lets staff block time, cancel in bulk and assign units.
This page walks a bookable product from its published configuration to a confirmed reservation on an order, in the order a storefront actually calls the APIs: read the booking configuration from the Catalogue API, ask the Shop API what is available, hold a slot, keep the hold alive while the shopper shops, place the cart, create the order, and confirm the booking. It also covers the booking admin endpoint at a high level and how you configure booking in the Crystallize app.
The flow is one-directional. You author a booking policy and mark a product bookable in the app (PIM); publishing copies a frozen snapshot of that configuration to the Catalogue API; the Shop API reads that published snapshot and writes reservations. Cart mechanics that are not booking-specific (hydrating, placing, creating orders) are documented on the Shop API pages — this page links to them rather than repeating them, and covers only what booking adds. App-side management of individual reservations lives on the Reservations page.
Booking spans four APIs. Each serves a different surface and needs a different scope.
| API / endpoint | Serves | Scope(s) |
|---|---|---|
| Catalogue API | Reads the published bookable config (pool + policySnapshot). Published data only. | Catalogue read access |
| Discovery API | Product listing and search — does NOT expose bookable data (see below). | n/a for booking |
| Shop API — POST /{tenant}/cart | availability, nearestAvailability, checkBooking, bookSkuItem, cart reservations. | cart |
| Shop API — POST /{tenant}/order | Creates the order from the cart (snapshots the booking). | order |
| Shop API — POST /{tenant}/booking/admin | Admin: calendar, block, cancel, bulk operations, assign unit, set metadata. | booking AND booking:admin |
The Shop API derives the required scope from the URL path: the second path segment is the base scope, and a fourth segment demands the matching :admin scope — so /booking/admin needs both booking and booking:admin. Note there is no GraphQL endpoint at /{tenant}/booking; only /{tenant}/booking/admin exists.
The x-crystallize-environment header selects the API origin your credentials are validated against, and it defaults to prod. Send a valid dev credential pair without the header and it is checked against prod — where that tenant does not exist — and the response is "Invalid access token pair provided. Status: Forbidden." The credentials are fine; the request went to the wrong environment. Send this header on every Shop call (not just the token mint), because the cart and booking paths resolve the catalogue through the same client.
Before any of the API calls below return bookable data, an author configures the feature in the Crystallize app and publishes:
Marking a product bookable freezes a snapshot of the policy's durations onto the product (with the policy's version). Editing a policy afterwards does not fan out to products that reference it — a product keeps the terms it was configured under, and the app shows a "differs from policy" warning until an author re-applies the policy to accept the new terms. Re-applying, like every edit, reaches the Shop only on the next publish.
Start by reading the product's published bookable field. A product exposes bookable with three parts: the policyId, the pool union, and the frozen policySnapshot. This is the exact query the Shop API itself runs to resolve a product's booking terms.
query RESOLVE_BOOKABLE($skus: [String!]!, $language: String!) {
productVariants(skus: $skus, language: $language) {
product {
id
bookable {
policyId
pool {
... on BookableUnitPool { kind units { id meta { key value } } }
... on BookableCapacityPool { kind capacity }
}
policySnapshot {
bufferBefore
bufferAfter
cancellationWindow
advanceWindow
pendingHoldDuration
placedHoldDuration
version
}
}
}
}
}query RESOLVE_BOOKABLE($skus: [String!]!, $language: String!) {
productVariants(skus: $skus, language: $language) {
product {
id
bookable {
policyId
pool {
... on BookableUnitPool { kind units { id meta { key value } } }
... on BookableCapacityPool { kind capacity }
}
policySnapshot {
bufferBefore
bufferAfter
cancellationWindow
advanceWindow
pendingHoldDuration
placedHoldDuration
version
}
}
}
}
}Three things about this query are load-bearing:
The Discovery API has no bookable types today and its product has no bookable field. Discovery is the API most storefronts use to list and search products, so a Discovery-first storefront cannot render a pool — unit ids, unit names, and unit metadata (where a floor-plan or seat-map storefront keeps its geometry) are unreachable there.
The supported workaround is a second call to the Catalogue API for that one read, using the RESOLVE_BOOKABLE query above, keyed by the SKUs Discovery returned. Do not invent a Discovery bookable field — it does not exist. Exposing bookable on Discovery to mirror the Catalogue shape is requested but not yet scheduled.
With the pool known, ask the Shop API what is free. availability returns per-bucket occupancy over a range, and nearestAvailability suggests the closest open windows to a target time. Both are served live from the reservation ledger and account for the policy's buffers automatically, so you cannot compute the answer client-side from a list of reservations.
query {
availability(
productId: "{productId}"
sku: "{sku}" # required
range: { start: "2026-08-01T08:00:00Z", end: "2026-08-01T18:00:00Z" }
granularitySec: 3600 # required — bucket size in seconds
language: "en" # required, no default
) {
start
end
free # remaining occupancy in the bucket
freeUnitIds # which units are free — [] on a capacity pool
bookable # false in the past or beyond the advance window
reason
}
nearestAvailability(
productId: "{productId}"
sku: "{sku}" # required
around: "2026-08-01T14:00:00Z"
durationSec: 3600
n: 5 # optional, defaults to 5
language: "en" # required, no default
) {
start
end
}
}query {
availability(
productId: "{productId}"
sku: "{sku}" # required
range: { start: "2026-08-01T08:00:00Z", end: "2026-08-01T18:00:00Z" }
granularitySec: 3600 # required — bucket size in seconds
language: "en" # required, no default
) {
start
end
free # remaining occupancy in the bucket
freeUnitIds # which units are free — [] on a capacity pool
bookable # false in the past or beyond the advance window
reason
}
nearestAvailability(
productId: "{productId}"
sku: "{sku}" # required
around: "2026-08-01T14:00:00Z"
durationSec: 3600
n: 5 # optional, defaults to 5
language: "en" # required, no default
) {
start
end
}
}Every argument to availability is non-null — including sku and language, which readers often expect to be optional. sku is required because the pool and policy are read off the variant; since bookable lives on the product, any variant SKU resolves the same pool. language is required and deliberately undefaulted for the same reason as on the Catalogue read: a wrong language reads back as "not bookable" rather than as an error. granularitySec buckets the range — pass the window's own duration to get a single bucket covering it. freeUnitIds is always empty for a capacity pool, because seats have no identity.
Before you offer a specific slot, checkBooking runs the same admission gates a hold would, without writing a row. Use it as the submit gate on a booking form. It returns ok plus a reason.
query {
checkBooking(
input: {
productId: "{productId}"
sku: "{sku}"
start: "2026-08-01T14:00:00Z"
end: "2026-08-01T15:00:00Z"
unitId: "room-1" # optional; requires quantity 1
quantity: 1 # optional, default 1
language: "en" # required
}
) {
ok
reason
}
}query {
checkBooking(
input: {
productId: "{productId}"
sku: "{sku}"
start: "2026-08-01T14:00:00Z"
end: "2026-08-01T15:00:00Z"
unitId: "room-1" # optional; requires quantity 1
quantity: 1 # optional, default 1
language: "en" # required
}
) {
ok
reason
}
}This is the cart endpoint's checkBooking; it pins the source to a customer cart and applies the customer advance window. The booking admin endpoint has its own checkBooking that takes a source instead and refuses CART — see the admin section below. Debounce checkBooking on the storefront: each call is one turn of the tenant's booking authority.
Adding a bookable line places a PENDING hold on the cart. Presence of the booking object on the item input selects the bookable path. The hold lives for the policy's pendingHoldDuration and then lapses on its own.
mutation {
bookSkuItem(
id: "{cartId}"
input: {
sku: "{sku}"
quantity: 1
booking: {
start: "2026-08-01T14:00:00Z"
end: "2026-08-01T15:00:00Z"
unitId: "room-a" # omit on a capacity pool
}
}
) {
__typename
... on Cart {
id
items {
name
quantity
variant { sku } # CartItem has no sku of its own
meta # HashMap scalar — no sub-selection
metaProperty(key: "reservationIds")
}
}
... on ReservationConflict { message }
... on NotBookable { message }
... on InvalidRange { message }
... on InvalidUnitId { message }
}
}mutation {
bookSkuItem(
id: "{cartId}"
input: {
sku: "{sku}"
quantity: 1
booking: {
start: "2026-08-01T14:00:00Z"
end: "2026-08-01T15:00:00Z"
unitId: "room-a" # omit on a capacity pool
}
}
) {
__typename
... on Cart {
id
items {
name
quantity
variant { sku } # CartItem has no sku of its own
meta # HashMap scalar — no sub-selection
metaProperty(key: "reservationIds")
}
}
... on ReservationConflict { message }
... on NotBookable { message }
... on InvalidRange { message }
... on InvalidUnitId { message }
}
}The result is a union of Cart plus four error members, each carrying only message. Their meanings:
Three selection details that fail validation if you guess: CartItem.meta is a HashMap scalar — select it bare, never as meta { key value }; the cheaper read of a single entry is metaProperty(key: "reservationIds"). A CartItem has no sku and no selectable lineId — the SKU is variant { sku }, and you address a hold by its reservation id from meta, not by a line id.
bookSkuItem's id argument is nullable, but passing null does not create a cart — the mutation half-runs and then fails serializing a Cart with a null id. Create the cart first with hydrate(input: { items: [] }) and book against the id it returns.
mutation {
hydrate(input: { items: [] }) { id }
}A PENDING hold expires at now + pendingHoldDuration. Hydrating a draft cart slides that deadline forward before it does anything else: every hydrate pushes each hold's expiresAt out to now + pendingHoldDuration and reports which holds have already died. Re-hydrating with the full booking set while the shopper is active is therefore the supported way to auto-renew — there is no separate "extend hold" mutation. The cart shares one deadline for all its booking lines, computed from the smallest pendingHoldDuration among them, so its holds expire as a group.
hydrate is not read-only for bookings: it reconciles the cart's stored booking lines against the items you send. An unchanged window is left alone, a moved one is rebooked, a quantity change admits or releases the delta, and a booking line you leave out of the payload is released. Always send the full booking set on every hydrate. Reconciliation is all-or-nothing: if the ledger refuses any part, the whole hydrate is rolled back and throws BookingNoLongerAvailable, and the cart still names the windows it named before. A hold that has already lapsed is not renewed — it is reported as missing so the reconciler re-admits it (or fails the hydrate).
hydrate throws on a placed cart and serves an ordered cart read-only. For general cart hydration mechanics, see Update Cart; this page covers only what booking adds.
To show a hold's live state and deadline, query reservation with both the cartId and the reservation id. Both arguments are required, and that is the fence: the ledger is scoped by tenant, so a bare id would expose another customer's row. A reservation this cart does not hold reads as null — the same as an id that does not exist — so the field cannot be used to probe which ids exist. The returned type is CartReservation, not the admin Reservation; the two are served under the same tenant prefix but carry different fields.
query {
reservation(cartId: "{cartId}", id: "{reservationId}") {
id
state
unitId
start
end
expiresAt
}
}query {
reservation(cartId: "{cartId}", id: "{reservationId}") {
id
state
unitId
start
end
expiresAt
}
}Change a booking before you place the cart, either by re-hydrating with the full set or with cancelReservation / rebookReservation (both cartId and reservationId are UUID, and both are refused on a placed or ordered cart with a thrown InvalidStateError). After the cart is placed, changes go through the booking admin API.
mutation {
place(id: "{cartId}") {
id
state
}
}mutation {
place(id: "{cartId}") {
id
state
}
}Do not skip place. It is the last point where a booking that has gone bad still fails in front of the customer — everything after it happens after the money has moved. On top of re-hydrating against the live catalogue, place does two checks hydration alone does not: it compares every booking line against the pool being served now (catching a cleared configuration, a shrunk pool, a deleted unit, or an unpublished product), and it verifies the ledger still holds each reservation. A policy change deliberately does not invalidate a line — the frozen policy is the contract the customer booked under.
place is also where the hold duration extends past placement: each hold is moved onto the placed-but-unpaid window using its own frozen placedHoldDuration (falling back to pendingHoldDuration when placedHoldDuration is 0). Set a longer placedHoldDuration in the policy when payment takes days, such as cheque or bank transfer; leave it at 0 to keep the pending expiry.
Either failure throws BookingNoLongerAvailable (HTTP 409). It computes a per-line reason internally (NotBookable, UnitNoLongerInPool, HoldNoLongerHeld, ReservationConflict, InvalidRange), but that structure does not reach the wire — you get only a flattened message of the form "Cannot place this cart: {sku} ({reason}), …". Parse it, or re-derive the state from availability or the cart's own reservation query. A capacity pool shrinking below what is already held is not detectable at placement, because capacity is a count with no identity — the ledger resolves that at admission.
mutation {
createFromCart(id: "{cartId}") {
id
}
}mutation {
createFromCart(id: "{cartId}") {
id
}
}Order creation does not confirm anything. It reads the reservations as they stand and freezes them onto the order under the reservations meta key — a read of the ledger, never a write. Any id the ledger cannot return is reconstructed from the cart line so the order still records a window, marked as EXPIRED and flagged as reconstructed so no reader takes it as observed fact; ids the ledger reports as genuinely lost are recorded under reservations.unconfirmed and logged. A live PENDING hold is not lost and is not flagged. Because createFromCart is also used by POS, imports, and direct order creation — not only checkout — it deliberately does not confirm as a side effect. The frozen booking travels with the order; see Orders for reading orders back.
Confirmation is a separate, tenant-triggered step — nothing confirms for you, and a cart that is never confirmed keeps holds that lapse on schedule. Confirming moves the reservations from PENDING to CONFIRMED.
mutation {
confirmCartBooking(cartId: "{cartId}") {
__typename
... on Cart { id }
... on NotPlaced { message }
... on NothingToConfirm { message }
... on ReservationNoLongerHeld { message missing }
}
}mutation {
confirmCartBooking(cartId: "{cartId}") {
__typename
... on Cart { id }
... on NotPlaced { message }
... on NothingToConfirm { message }
... on ReservationNoLongerHeld { message missing }
}
}Call it once the customer is committed — the right moment depends on the tenant: a pay-first tenant confirms from its payment webhook, a pay-on-arrival tenant at placement, a quote tenant days later. Because of this, confirmCartBooking is valid on a Placed cart and on one that has already become Ordered (the webhook by definition arrives after the order exists).
Confirmation is all or nothing: if any hold is gone, nothing is confirmed, the survivors stay PENDING, and the failed ids come back in ReservationNoLongerHeld.missing so you can rebook the lost slot and retry. When an order already exists, confirming also stamps the order id onto the ledger rows and re-freezes the order from the just-confirmed reservations, clearing the reservations.unconfirmed flag. CONFIRMED rows stay in the ledger and keep blocking the slot until their window ends; they are never swept.
A reservation moves PENDING → CONFIRMED → COMPLETED, or leaves early via EXPIRED (a lapsed cart hold) or CANCELLED (an admin cancel). Only CART reservations expire; admin holds have no deadline and persist until cancelled. The reservation ledger is a working set — pending rows, future confirmed rows, and roughly 90 days of terminal ones — not an archive. For durable booking history, read the frozen reservations meta on the order rather than the ledger.
POST /{tenant}/booking/admin is the operator-facing endpoint and needs both the booking and booking:admin scopes. It reads and writes the same ledger from the staff side. The app flow that drives it is documented on the Reservations page; this is the API-level overview.
mutation {
block(input: { productId: "…", sku: "…", quantity: 6 }) { id state }
cancel(id: "…", reason: "…") { id state }
assignUnit(id: "…", unitId: "room-b", language: "en") { id unitId }
setMeta(id: "…", meta: [{ key: "note", value: "vip" }], merge: true) { id }
}mutation {
block(input: { productId: "…", sku: "…", quantity: 6 }) { id state }
cancel(id: "…", reason: "…") { id state }
assignUnit(id: "…", unitId: "room-b", language: "en") { id unitId }
setMeta(id: "…", meta: [{ key: "note", value: "vip" }], merge: true) { id }
}Every id argument on the admin schema is a String (not the UUID the cart schema uses), so reuse a UUID variable declaration carefully across the two endpoints.