bookable-resources
npx skills add https://github.com/crystallizeapi/ai --skill bookable-resourcesCrystallize Bookable Resources
A bookable product is not counted down like stock — it is held for a window of time and given back. A digger rented Friday to Monday, a meeting room at 09:00, a photographer for an afternoon. The same product sells again and again; what is scarce is the calendar.
Crystallize serves this natively: a policy carries the rules, a pool carries the things that can be booked, and the Shop API holds them for a shopper while they shop, then hands them to the order.
Verified on 2026-09-24 and 2026-09-28 against the live Core API and Shop API on a tenant with bookable resources, including test reservations taken and released. Where a claim comes from one storefront build rather than from the API, this skill says so.
Five concepts
| Concept | What it is |
|---|---|
| Policy | The rules, all durations in seconds: how far ahead, buffers, how long a hold lives, cancellation window |
| Pool | What can be booked on one product: named units (machine 1, machine 2) or a plain capacity |
| Reservation | One hold on one window, in one cart line. It expires unless it is confirmed |
| Window | start and end, absolute times. Availability is asked and holds are taken per window |
| Confirmation | What turns a hold into a booking that survives. It must run after the cart has its orderId — see below |
Booking sits on the product; the price comes from the variant. A rental sold by the day, the weekend and the week is therefore one bookable product with three variants.
The pipeline
Core createBookingPolicy the rules — every duration in SECONDSCore setBookable(id, language) one pool per product: units[] OR capacity, never bothCore publishItem the Shop API reads published data only — publish after EVERY setBookable, reapplyBookablePolicy or clearBookableShop availability / checkBooking what is free, and would this exact booking be takenShop bookSkuItem a hold on the cart, in state PENDINGShop place re-checks the holds and extends them to placedHoldDurationShop createFromCart the order. It only snapshots the reservationsShop confirmCartBooking once the cart has its orderId (optionally also before payment)Everything a storefront does is on Discovery and the Shop API. Core is for setup only — policies, pools and publishing are admin-time work, never called from a storefront at runtime.
Is this tenant bookable?
There is no feature flag: every tenant has booking policies, and role permissions are the gate. Ask for the policies to find out whether this session can manage them:
{ bookingPolicies(first: 1) { __typename ... on BookingPolicyConnection { totalCount } ... on BasicError { errorName message } }}A connection means yes. FORBIDDEN means the role lacks the bookingPolicies permission. That
usually happens on a custom role created before bookings existed, and it is fixed on the role, not in
the query.
Failure modes
Three of these are silent — a paid booking whose reservation has no order, a pool or policy change that has no effect, and a booking that vanishes when two are taken at once. They are the reason this skill exists; the rest name themselves.
| Symptom | Cause | Fix |
|---|---|---|
| Admin shows “No order — not checked out” on a paid booking | confirmCartBooking ran before the cart had its orderId |
Confirm again after createFromCart — see booking-flow |
NotBookable on a product you just made bookable |
The item is not published, or the query’s language is wrong |
Publish it; pass the language the item exists in |
| A pool or policy change has no effect in the Shop | Bookable edits are drafts; products keep the policySnapshot they were set with |
reapplyBookablePolicy for a policy change, then publish the product |
FORBIDDEN from createBookingPolicy |
The role has no bookingPolicies permission |
Grant it on the role |
Holds vanish within seconds, or InvalidRange on every date |
A duration was sent in minutes, hours or days | Every policy duration is seconds; read humanized back to check |
hydrate throws “A placed cart cannot be hydrated” |
The cart is placed; its contents are frozen | Change bookings before place, or through /booking/admin after |
ReservationConflict on a unit that looked free |
Someone took it between the availability query and the booking | Walk the other freeUnitIds and retry |
| Two bookings taken at once, one of them missing | Concurrent bookSkuItem on one cart — both answered Cart |
Serialize booking writes per cart, then read the cart back |
InvalidRange on a date far ahead |
The window is past the policy’s advanceWindow |
Widen the policy, or cap the calendar at the shortest advanceWindow in the basket |
CancellationWindowClosed when removing a basket line |
The window applies to holds that were never bought | Re-hydrate the cart without that line instead |
BookablePoolKindChangeError |
Capacity → units while a capacity pool is published | clearBookable, publish, cancel or wait out open reservations, then set units |
BookingPolicyInUseError on delete |
Products still reference the policy (stats.referencingProductCount) |
Move those products to another policy and publish them |
References
- references/policies-and-pools.md — Core: policies, unit and capacity pools, snapshots and reapplying, deleting and clearing.
- references/booking-flow.md — Shop API
/cart: availability, holds, checkout, the double confirmation, cancelling and moving a booking. - references/modelling.md — periods as variants, units as real machines, what Discovery serves, and how a rental relates to the product it is a rental of.
Related: [[mutation]] for the Core API mutations themselves, [[query]] for Discovery and the Shop API query surface, [[pricing]] for what a booked variant costs.
Reference Details
The booking flow (Shop API /cart)
Everything the storefront does. All of it is on the /cart endpoint except the order itself, which is
/order. See [[mutation]] for tokens and endpoints.
1. What is free
query Availability($productId: String!, $sku: String!, $range: CartTimeRangeInput!, $language: String!) { availability(productId: $productId, sku: $sku, range: $range, granularitySec: 86400, language: $language) { start end free freeUnitIds bookable reason }}One slot per granularitySec across the range: 86400 for a day view, 3600 for hours. free is how
many units are open, freeUnitIds names them, and reason says why a slot is closed.
languageis required and it must be a language the item exists in. The wrong one reads asNotBookablerather than as an error.- Ask for the range the calendar shows, plus enough tail that the longest bookable period starting on the last visible day still fits.
nearestAvailability(productId, sku, around, durationSec, n, language) answers “the next n windows of
this length near this time” — the right query behind a “next free slot” button.
checkBooking(input: CartCheckBookingInput!) is a dry run of one exact booking and returns
{ ok, reason }, not a union. It takes productId, sku, start, end, language, and optionally
unitId and quantity. Debounce it: it runs the same gates the booking itself does.
2. Hold it
mutation Book($id: UUID, $input: CartBookingItemInput!) { bookSkuItem(id: $id, input: $input) { __typename ... on Cart { id items { lineId name meta } } ... on ReservationConflict { message } ... on NotBookable { message } ... on InvalidRange { message } ... on InvalidUnitId { message } }}{ "input": { "sku": "RENT-GAS55-DAY", "quantity": 1, "booking": { "start": "2026-10-01T08:00:00Z", "end": "2026-10-01T16:00:00Z", "unitId": "GAS55-OSL-1" }, "meta": [{ "key": "unitId", "value": "GAS55-OSL-1" }] }}bookSkuItem is addSkuItem with a window: the line is priced from the SKU like any other line.
Check __typename. The four refusals are results, not GraphQL errors, so a client that only looks at
errors treats a refused booking as a success.
quantity is units out of the pool, not the length of the window
This is the single thing most likely to produce a wrong price. Measured on a unit pool of 5, booking a two-day window on a one-day SKU priced at 39 net:
| Sent | Result |
|---|---|
quantity: 1 |
one reservation over the whole window, line 39 — not 78 |
quantity: 2 |
two reservations, both over the whole window, line 78 |
quantity: 6 (pool holds 5) |
ReservationConflict |
quantity: 2 with a unitId |
Output validation error (OUTPUT_VALIDATION_ERROR) — a bug |
So quantity: 2 means two machines, or two rooms, for the same window — never “two days” or “two
nights”. Nothing in the booking inputs prices by duration. A window twice as long costs the same
unless the price says otherwise, and the price only comes from the SKU.
Two ways out, and the first is the one to reach for:
- Sell the duration as variants — a day SKU, a weekend SKU, a week SKU — and book the SKU whose length matches the window. See modelling.md.
- When the shopper picks arbitrary dates (hotel nights, hourly hire), book one managed line for the
first unit of time and add the rest as an external line (
addExternalItem) that carries the remaining nights, tied back to the booked line by your ownmeta. Do not reprice the booked line:placerefuses a cart whose booked line went throughchangeCartItemPricingwithNotBookable, even though the reservation itself survives it. A group discount works as a negative external line. (From the Boutique Universe build, a hotel on nightly rates.)
A unitId and quantity: 1 belong together. To hold two named units, book twice — once per unit.
Put the customer on the cart before booking. The reservation records who holds it at
bookSkuItem/hydrate time, and only when the cart’s customer has an identifier. Nothing sets it later.
Create the cart with hydrate(input: { customer, items: [] }), or setCustomer before the first booking.
On ReservationConflict, walk the other freeUnitIds. Between the availability query and the
booking someone else may have taken that unit. Any other refusal is final — stop and tell the shopper.
A window past the policy’s advanceWindow is refused as InvalidRange, with nothing to say the
policy is what stopped it. Products that are booked together need the same window: a room on a 120-day
policy and a boat trip on a 60-day one means a summer booking can hold the room but not the trip. Cap
the calendar at the shortest advanceWindow in the basket. (Observed in the Boutique Universe build.)
The reservation id comes back on the line’s meta, not on the cart’s:
"meta": { "reservationIds": "18a6b9d4-…", "booking.window": "2026-10-01T08:00:00.000Z/2026-10-01T16:00:00.000Z" }booking.window is written for you. Write the unitId into the line’s meta yourself. CartItem
has no booking field, and a pinned line re-hydrated without its unitId does not match its hold: it
is rebooked, possibly onto another unit. reservation(cartId:, id:) { unitId } can recover a lost one.
bookSkuItem ignores group and type on a new line. To group a booking with its services, set
group when you next hydrate.
Read one hold with reservation(cartId:, id:):
{ id, productId, variantSku, unitId, start, end, state, source, cartLineId, orderId, expiresAt }.
Look holds up one at a time. Several reservation(cartId:, id:) fields aliased into a single query
sometimes never answer — 3 of 10 attempts in the Boutique Universe build. One query per hold, with a
timeout.
One hold at a time per cart
Two bookSkuItem calls in flight against the same cart lose one of them, and both answer Cart.
Measured on a unit pool of 5, two different windows, no unitId, 3 of 3 attempts:
- one line survives; the other booking is simply not in the cart
- the lost hold is orphaned —
reservation(cartId:, id:)answersnullwhileavailabilitystill counts its unit as taken (free: 4of 5) - emptying the cart releases the surviving line only. The orphan lets go by itself after
pendingHoldDuration(15 minutes on that policy), andfreegoes back to 5
So serialize the booking writes for one cart — a queue or a lock per cart id — and never read a
Cart reply as proof the line is there: read the cart back and find the line by your own meta key.
Reported to Crystallize engineering on 2026-09-28.
3. Keep it while they shop
hydrate is the whole cart. A booking line you leave out is cancelled with it, and re-hydrating with
the line slides its expiry forward. There is no “extend the hold” mutation.
That cuts both ways, and the second half is the useful one:
-
To keep a booking, send every line on every hydrate, with its window and unit:
{"sku": "RENT-GAS55-DAY","quantity": 1,"lineId": "…","booking": { "start": "2026-10-01T08:00:00Z", "end": "2026-10-01T16:00:00Z", "unitId": "GAS55-OSL-1" },"meta": [{ "key": "unitId", "value": "GAS55-OSL-1" }]}A line is matched to its hold by window and unit, so resend both unchanged.
lineId(selectable onCartItem) is the line’s server-minted handle; send it back when two lines share a SKU. -
To remove one from the basket, re-hydrate without it. Do not use
cancelReservation: the policy’s cancellation window applies to a hold that was never bought, so a rental starting tomorrow under a two-day window answersCancellationWindowClosedand the shopper cannot empty their own basket. Verified against a live tenant.
cancelReservation(cartId:, reservationId:) succeeds only while the start is at least
cancellationWindow seconds away. It is right for a line that is far enough out, and
rebookReservation(cartId:, reservationId:, newBooking:) moves a hold to a new window atomically. It
moves every reservation on that line, so a quantity-3 line moves as one. ReservationConflict is the
only outcome worth retrying.
All of this is for a draft cart. Once the cart is placed, hydrate throws “A placed cart cannot be
hydrated”, and cancelReservation/rebookReservation throw InvalidStateError. Finish every change to
the bookings before place.
4. Place, pay, order, confirm — in that order
place(id) re-checks every hold, extends to placedHoldDuration (0 = pendingHoldDuration)confirmCartBooking(cartId) OPTIONAL: the holds still stand, and are now committed — see below… take payment …createFromCart(id, input) the order (on /order). It snapshots the reservations… wait for cart.orderId … the cart is linked in the background, ~500 msconfirmCartBooking(cartId) REQUIRED: this is what writes orderId onto the reservationsconfirmCartBooking writes orderId onto the reservations only if the cart already has one. The
order id is the cart id, but createFromCart returns before it links the cart: it saves the order
and stamps orderId on the cart in the background. The cart therefore gets its orderId a few
hundred milliseconds after createFromCart returns, so a single confirm
before the order leaves every reservation { state: CONFIRMED, orderId: null }, and the admin shows
“No order — not checked out” for a booking that was paid for.
Poll the cart, then confirm again. It accepts an ordered cart and rows that are already confirmed:
async function linkReservations(cartId: string) { for (let i = 0; i < 20; i++) { const { cart } = await shop(`query($id: UUID!) { cart(id: $id) { orderId } }`, { id: cartId }); if (cart?.orderId) { await shop(`mutation($id: UUID!) { confirmCartBooking(cartId: $id) { __typename } }`, { id: cartId }); return; } await new Promise((r) => setTimeout(r, 250)); }}The first confirm commits the slot before the money moves. A CONFIRMED reservation never expires:
it blocks the calendar until its window ends. It is what proves the holds still stand before the
shopper is charged. If you keep it, cancel the reservations through /booking/admin when the payment
fails, or the machine stays blocked with no order behind it. If you drop it, place is still the
last check before payment, and the holds live for placedHoldDuration while payment runs.
confirmCartBooking answers Cart, NotPlaced, NothingToConfirm or ReservationNoLongerHeld. The
last one means a hold expired while payment was in flight; select its missing field for the ids. It
is all or nothing: the surviving holds stay PENDING, and the shopper has to pick another window for
the lost one.
When payment is confirmed server-side by a gateway webhook, run the confirmation there rather than in the browser round trip.
Do not call fulfill. createFromCart already moves the cart to ordered, and the order id is the
cart id.
After the order
-
A reservation on a placed or ordered cart cannot be cancelled through the cart: “A reservation cannot be cancelled through a cart that is no longer editable.” Cancel it on the Shop API’s
/booking/adminendpoint, which needs a token with both thebookingandbooking:adminscopes and ignores the cancellation window. Run it server-side only:mutation {cancel(id: "18a6b9d4-…", reason: "payment failed") {idstate}}bulkCancel(ids:, reason:)takes up to 100 ids and answers per id; it is not atomic. -
A hold lost after
placecannot be re-picked on that cart. A placed cart cannot be hydrated or rebooked. OnReservationNoLongerHeld, cancel the survivors (above), refund if the money has moved, and start a new cart for the new window. -
A reservation’s
stateis a string:PENDING,CONFIRMED,COMPLETED,CANCELLEDorEXPIRED. A hold that runs out becomesEXPIRED, up to a minute late, while its line stays in the cart. The nexthydratetakes it again if the window is still free and throwsBookingNoLongerAvailableif not;placerefuses it withHoldNoLongerHeld.
Modelling a bookable catalogue
Booking is on the product, price is on the variant
setBookable takes an item id, so the calendar belongs to the product. The money belongs to the
variant. That one split decides the model: sell time as variants.
Product Bosch GAS 55 M dust extractor — rental bookable: 5 units, policy "heavy-equipment" ├─ RENT-GAS55-DAY 1 day attributes: { period: day } ├─ RENT-GAS55-WEEKEND weekend attributes: { period: weekend } ├─ RENT-GAS55-WEEK 1 week attributes: { period: week } └─ RENT-GAS55-4WEEKS 4 weeks attributes: { period: 4-weeks }The shopper picks a period and a start; the storefront turns that into start/end and books the
matching SKU. Keep the length of each period on the variant — a numeric duration-hours, or an
attribute — so the window is computed from data rather than from a hardcoded table. A weekend is rarely
48 hours: Friday 12:00 to Monday 08:00 is 68.
Price tiers per period, currency per market and VAT all work as they do for any other variant — see [[pricing]].
Units are the real things
A unit id should be the thing in the world: an asset tag, a room number, a registration number. Put
everything that distinguishes it in meta:
{ "id": "GAS55-OSL-1", "meta": [ { "key": "depot", "value": "osl" }, { "key": "serial", "value": "TU316652" }, { "key": "hours", "value": "257" }, { "key": "lastService", "value": "2026-06-07" } ]}That is what lets a storefront filter by location (“available in Oslo this weekend”) without another
data source: read the pool from Discovery, group the freeUnitIds from availability by their depot
meta, and show one line per location.
Use a capacity pool instead when the units are interchangeable and nobody needs to know which one they got — seats on a course, bikes in a rack. You lose per-unit meta, so if the storefront must say which one, use units.
What Discovery serves, and what it does not
Discovery carries the static bookable configuration on the item:
{ browse { rental(language: en, pagination: { limit: 24 }) { hits { itemId name path bookable { poolSize pool { __typename ... on BookableUnitPool { units { id meta { key value } } } ... on BookableCapacityPool { capacity } } } variants { sku attributes defaultPrice } } } }}Discovery never serves availability. Listing pages can say “5 machines, 3 depots” from poolSize and
the pool; anything about a date is a Shop API call. Design the page so the calendar loads after the
product, not as part of the listing query — one availability call per visible card is a lot of calls.
Rentals next to the thing being rented
A rental item and the product it is a rental of are two catalogue items. Relate them both ways: an item relation from the rental to the tool, and one back from the tool to its rental. The tool’s page can then offer “rent this instead”, and the rental page can show the tool’s specifications without duplicating them. See [[content-model]] for the relation component and [[information-architecture]] for where the rental folder sits.
Mark the rental folder with an externalReference (folder:/rental) so the storefront can recognise a
rental listing without matching on the path in four languages.
Services around a booking
Delivery, damage waiver, cleaning, an operator: sell them as ordinary products and add them to the cart
as normal lines, or as type: service lines. They are not bookable themselves; they follow the booking
they belong to. Give them and the booking line the same group so the basket can show them together.
Set group and type through hydrate: bookSkuItem ignores both on a new line.
Booking policies and pools (Core API)
Setup for bookable products. Admin-time work: a storefront never calls these.
The policy
A policy is a named set of rules shared by many products. Every duration is a number of seconds.
mutation CreatePolicy($input: CreateBookingPolicyInput!) { createBookingPolicy(input: $input) { __typename ... on BookingPolicy { id name version humanized { advanceWindow { value unit } cancellationWindow { value unit } } } ... on BasicError { errorName message } }}{ "input": { "name": "heavy-equipment", "advanceWindow": 10368000, "bufferBefore": 0, "bufferAfter": 14400, "cancellationWindow": 172800, "pendingHoldDuration": 900, "placedHoldDuration": 86400 }}| Field | Required | Meaning |
|---|---|---|
name |
yes | Unique per tenant — BookingPolicyNameTakenError otherwise |
advanceWindow |
yes | How far into the future a booking may be made (120 days = 10368000) |
bufferBefore |
yes | Dead time reserved before each booking |
bufferAfter |
yes | Dead time after — cleaning, charging, travel (4 hours = 14400) |
cancellationWindow |
yes | How close to the start a booking may still be cancelled (2 days = 172800) |
pendingHoldDuration |
yes | How long a hold in a live cart survives (15 minutes = 900) |
placedHoldDuration |
no | How long a hold survives after place, while payment happens (1 day = 86400) |
A placedHoldDuration of 0, or none, gives a placed cart a fresh pendingHoldDuration. Set it
when payment takes longer than a live hold, such as an invoice or a bank transfer.
Read humanized back after writing. It returns the same values as { value, unit } in days, hours,
minutes or seconds, which is the cheapest way to catch a duration that was sent in the wrong unit. The
mistake is silent otherwise: a cancellationWindow of 2 is two seconds, not two days.
updateBookingPolicy(id, input) takes the same fields, all optional, and bumps version.
bookingPolicies(first:) returns a connection (edges { node { … } }), not a list.
bookingPolicy(id:) reads one. BookingPolicy.stats.referencingProductCount says how many products use
it, and deleteBookingPolicy refuses with BookingPolicyInUseError while that count is above zero.
The pool
setBookable attaches a policy and says what can be booked. One product, one pool. language is
required, but the pool is not per language: every language of the product shares it, so set it and
reapply it once per product.
mutation SetBookable($id: String!, $language: String!, $input: GraphqlBookableInputInput!) { setBookable(id: $id, language: $language, input: $input) { __typename ... on BasicError { errorName message } }}Two kinds, and a product has exactly one of them:
{ "input": { "policyId": "6ab3…", "units": [{ "id": "GAS55-OSL-1", "meta": [{ "key": "depot", "value": "osl" }] }] } }{ "input": { "policyId": "6ab3…", "capacity": 8 } }| Pool | Use it for | What the shopper books |
|---|---|---|
units |
Real, distinguishable things: machine 1, room A, instructor Ada | One named unit, by unitId |
capacity |
Interchangeable seats: 8 places on a course, 20 bikes in a pile | One of N, no identity |
A unit’s meta is where its identity lives. Depot, serial number, running hours, last service — the
storefront reads it back from Discovery and can show “the machine in Oslo”. There is no other place to
put it.
Units → capacity is allowed. Capacity → units answers BookablePoolKindChangeError while the
published pool is a capacity pool, because capacity-era reservations carry no unit. Clear it with
clearBookable, publish that, cancel or wait out the open reservations, then set the units.
clearBookable(id, language) removes the pool, but only from the draft: the Shop keeps taking bookings
until the product is published again. Unpublish to stop bookings at once. bookableProducts lists
products with a published bookable configuration only. A product configured but never published
is not in it.
The snapshot
setBookable stamps the policy onto the product as a policySnapshot with its version:
{ item(id: "6ab3…", language: "en") { ... on Product { bookable { policyId poolSize policySnapshot { version cancellationWindow } pool { __typename ... on BookableUnitPool { units { id meta { key value } } } ... on BookableCapacityPool { capacity } } } } }}Editing a policy does not reach the products that use it. They keep their snapshot until
reapplyBookablePolicy(id, language) runs on each one. It re-freezes the policy’s current terms and
leaves the pool untouched, so do not re-run setBookable for this. Change the window, reapply, check
the product’s policySnapshot, then publish. Until the publish, the Shop still books under the old
terms. This is the step that makes “we changed the cancellation window and nothing happened” go away.
Reservations already taken keep the terms they were admitted under. A policy edit never changes a booking a shopper already holds.
Publish
The Shop reads the published bookable configuration only. A bookable product that is not
published answers NotBookable on every Shop API call, with no hint that publishing is what is
missing. setBookable, reapplyBookablePolicy and clearBookable all write to the draft, so each one
needs a publish before the Shop sees it. Publish per item and language with publishItem. See
[[mutation]].
Crystallize AI