Crystallize logo

Booking APIs: availability, holds and reservations

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.

note

Conventions used throughout

  • Durations are seconds. Every policy duration and every field of the frozen snapshot (buffers, windows, hold durations) is an integer count of seconds.
  • Ranges are UTC. Pass start/end as ISO-8601 instants in UTC (a trailing Z), e.g. 2026-08-01T14:00:00Z. end must be strictly greater than start.
  • Refusals are reported in three shapes. Read-only checks (availability, checkBooking) return a field value — a reason string or an ok boolean, never an error. Cart write mutations (bookSkuItem, rebookReservation, cancelReservation, confirmCartBooking) return a result union whose non-Cart members each carry a message; you must select them with inline fragments. A few operations throw instead — place throws BookingNoLongerAvailable (HTTP 409) with a flattened message, and the cart booking mutations throw InvalidStateError on a placed or ordered cart. Only BookingRejectedError carries a machine-readable reason in extensions.

Access: which endpoint serves which surface

Booking spans four APIs. Each serves a different surface and needs a different scope.

API / endpointServesScope(s)
Catalogue APIReads the published bookable config (pool + policySnapshot). Published data only.Catalogue read access
Discovery APIProduct listing and search — does NOT expose bookable data (see below).n/a for booking
Shop API — POST /{tenant}/cartavailability, nearestAvailability, checkBooking, bookSkuItem, cart reservations.cart
Shop API — POST /{tenant}/orderCreates the order from the cart (snapshots the booking).order
Shop API — POST /{tenant}/booking/adminAdmin: 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.

warning

Send x-crystallize-environment on every Shop API call

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.

Configure booking in the app

Before any of the API calls below return bookable data, an author configures the feature in the Crystallize app and publishes:

  1. Create a booking policy. A policy holds the reusable terms — buffer before/after each reservation, cancellation window, advance window, and the pending and placed hold durations. All are entered in seconds and the policy name is unique per tenant.
  2. Make a product bookable. In the product's Booking section, pick a policy and define the pool. The pool is exclusive: either a capacity (a number of interchangeable slots with no identity) or a set of named units (each with a unique id and optional metadata such as a floor or seat map) — never both. Booking is set per language, and lives on the product, so any of its variant SKUs resolves the same pool.
  3. Publish the product. The Shop API reads booking through the Catalogue API, which serves published data only. Until you publish, the Shop sees no bookable configuration and holds fail as NotBookable.

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.

1. Read the booking configuration (Catalogue API)

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
        }
      }
    }
  }
}

Three things about this query are load-bearing:

  • pool is a union (BookableUnitPool | BookableCapacityPool), so it needs inline fragments. A capacity pool returns kind and capacity; a unit pool returns kind and its units with their ids and meta. Selecting the fields flat is a validation error, not an empty result.
  • The frozen terms live in policySnapshot, and the policy version lives inside it — there is no resolved field and no top-level policyVersion field. It is this snapshot, not the live policy, that governs a booking. If policySnapshot is null the Shop treats the product as not bookable, because a configuration with no frozen terms cannot govern a booking.
  • language is required and has no default. The Catalogue API is fetched per language and returns an empty productVariants list for a language the tenant does not publish — indistinguishable from an unknown SKU. A wrong language does not error; it reads as "not bookable" for every product. Pass the same language the storefront hydrates its cart with.

Discovery does not expose bookable data

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.

2. Check availability (Shop API, /cart)

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
  }
}

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.

checkBooking — would this exact range be accepted?

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
  }
}

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.

3. Hold a slot (bookSkuItem) → PENDING

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 }
  }
}

The result is a union of Cart plus four error members, each carrying only message. Their meanings:

  • Cart — the hold was admitted. The reservation ids are stamped on the cart line meta under reservationIds as a comma-separated string (split it); a quantity-N booking produces N ids.
  • ReservationConflict — the slot is contended (capacity full or the unit overlaps another reservation, buffers included). Worth retrying unchanged.
  • NotBookable — the product has no published booking configuration for that language (unpublished, cleared, or wrong language).
  • InvalidRange — the window is malformed or violates the policy (end not after start, or beyond the advance window). Retrying will never help.
  • InvalidUnitId — the unitId is not in the pool. Retrying will never help. It is a separate member from ReservationConflict on purpose, so callers do not retry a request that cannot succeed.

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.

tip

Create the cart before you book against it

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 }
}

4. Keep the hold alive (hydrate)

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.

Read a hold back from the storefront

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
  }
}

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.

5. Place the cart — the last checkpoint before payment

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.

6. Create the order — a snapshot, not a commitment

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.

7. Confirm the booking → CONFIRMED

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 }
  }
}

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.

note

Reservation states and durable history

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.

The booking admin endpoint (overview)

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.

  • Read: calendar (forward view, optionally narrowed to a unitId), reservation (by id; unfiltered, so it may return a lapsed hold), the paged reservations list, reservationSummary (totals by state and source), and an admin checkBooking that takes a source and refuses CART.
  • Block: create maintenance or staff holds with block (sku required; returns one row per unit of quantity, all or nothing). These holds have no expiry.
  • Cancel: cancel bypasses the customer cancellation window unconditionally.
  • Bulk operations: bulkBlock and bulkCancel each return a per-item result and are NOT atomic — check every element. bulkCancel echoes the id on each result and caps a single call at 100 ids. For larger jobs, see Mass operations.
  • Assign a unit: assignUnit pins a reservation to a named unit and requires a language, because the pool it validates against is fetched per language.
  • Set metadata: setMeta writes reservation metadata, with merge to keep existing keys.
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.