Booking availability, holds & lifecycle
The booking API keeps three boundaries separate:
- Availability search is an on-demand answer, not a reservation.
- A hold transactionally claims finite capacity for a short period.
- Checkout or approval converts the intent into a booking and its canonical Sales projection.
All paths below are under /api/v1. Public booking calls require
x-organization-slug; do not send merchant credentials from a storefront.
Examples reuse the api() helper.
1. List active services
GET /public/booking-services returns only Active services for the selected
tenant. The response is keyset-paginated through nextCursor.
const page = await api("/public/booking-services?limit=24");
for (const service of page.data) {
console.log(
service.slug,
service.periodMode,
service.checkout.approvalMode,
service.checkout.paymentMode,
);
}A service describes:
periodMode:EXACT_RANGE,FIXED_DURATION, orFIXED_SLOT;- timezone, lead time, maximum advance, party-size limits, and duration choices;
- configured pricing with
authoritative: false; - approval/payment posture; and
- whether a returned slot needs an explicit customer resource selection.
Use GET /public/booking-services/:slug for one Active service. Missing,
draft, archived, and cross-tenant services remain neutral.
2. Search generated availability
POST /public/booking-availability/search intersects recurring service and
resource rules, then subtracts buffers, blackouts, active holds, and
capacity-holding bookings.
const availability = await api("/public/booking-availability/search", {
method: "POST",
body: JSON.stringify({
serviceSlug: "sixty-minute-massage",
startsAt: "2026-08-03T00:00:00-06:00",
endsAt: "2026-08-10T00:00:00-06:00",
timezone: "America/Boise",
quantity: 1,
}),
});
const slot = availability.slots[0];Send exactly one of serviceId or serviceSlug. For a non-default
fixed-duration choice, send durationMinutes. When a slot's
resourceOptions requires a customer choice, send the selected
requirementId, resourceId, and quantity in later searches and hold
creation.
Search ranges are bounded to 31 days. If truncated is true, narrow the
window; do not treat the returned list as complete.
slots[].pricing is display guidance only. The binding checkout/Sales
calculation owns the final total.
3. Claim a transactional hold
Create the hold immediately before checkout:
const holdKey = `booking:${crypto.randomUUID()}`;
const hold = await api("/public/booking-holds", {
method: "POST",
headers: { "x-idempotency-key": holdKey },
body: JSON.stringify({
serviceId: availability.serviceId,
startsAt: slot.startsAt,
endsAt: slot.endsAt,
quantity: 1,
resourceSelections: [],
customerEmail: "dana@example.com",
delivery: { method: "PICKUP" },
}),
});The server revalidates the exact interval, delivery posture, and every capacity
anchor under deterministic locks. A same-key/same-intent retry returns the
same hold; reusing that key for a different intent returns 409.
A hold lasts up to 30 minutes and can be shortened by the booking start.
Drive the countdown from serverTime, expiresAt, and expiresInSeconds
rather than the browser clock.
If the customer abandons the flow, release it early:
const releaseResponse = await fetch(
`${API}/public/booking-holds/${hold.id}`,
{
method: "DELETE",
headers: {
"x-organization-slug": "your-store",
"x-idempotency-key": holdKey,
},
},
);
if (!releaseResponse.ok) {
throw new Error(
`${releaseResponse.status} ${releaseResponse.statusText}`,
);
}The release endpoint returns 204 No Content, so do not pass its successful
response to a helper that always parses JSON.
The same idempotency key is also the possession proof for release and conversion. Keep it server-side or in a narrowly scoped checkout session; do not log it.
4. Convert the hold
POST /public/booking-holds/:id/checkout accepts customer identity and
optional address inputs. Reuse the hold key.
const result = await api(`/public/booking-holds/${hold.id}/checkout`, {
method: "POST",
headers: { "x-idempotency-key": holdKey },
body: JSON.stringify({
customerName: "Dana Reyes",
customerEmail: "dana@example.com",
}),
});Branch on the response's flow; do not infer it from your cached service:
flow | Meaning |
|---|---|
PAYMENT | A booking and canonical Sales record exist in PENDING_PAYMENT; checkoutToken is a raw c capability for the checkout-session flow. |
REQUEST | A capacity-free REQUESTED booking awaits merchant action. No checkout token exists. |
CONFIRMED | The booking is already confirmed, including an eligible zero-due result. |
TERMINAL | The booking or checkout is declined, cancelled, expired, or converging on expiry. |
A commercially bound PAYMENT response, or an eligible bound CONFIRMED
response, reports exact amountDueNowInCents and
remainingBalanceInCents. A REQUEST has not been priced yet; both fields are
zero sentinels until approval creates the Sales projection. Pay Later produces
no initial CheckoutSession or processor charge. Manual-approval and Pay Later
requests acquire capacity atomically when approved, not when submitted.
For PAYMENT, retain checkoutToken as a credential and continue through the
ordinary checkout-session read/payment endpoints described in
BYO checkout. Initialize Stripe.js from the returned
payment handoff; never create a PaymentIntent in the storefront.
The browser's Stripe result is not confirmation authority. A signed webhook moves the payment attempt, Sales record, and booking to their durable state. Poll/read the server result for recovery.
Delivery
The service declares PICKUP_ONLY, OPTIONAL, or REQUIRED.
- Send
{ "method": "PICKUP" }only when the service allows pickup. - For delivery, send a complete
destinationAddress.
Hold creation validates the address and configured radius band, then freezes the accepted method, address summary, distance, area, and fee. Checkout reuses that snapshot and fails closed when it is missing or malformed; it never silently turns delivery into pickup.
Canonical Sales ownership
An accepted booking links to the shared Commerce/Sales Order projection. Booking owns schedule, capacity, assignment, and lifecycle; Sales owns:
- authoritative totals and revisions;
- payment, deposit, balance, and refund ledger entries;
- quotes, invoices, contracts, and payment links; and
- shared fulfillment projection.
Do not build a second booking payment or document ledger. Cancellation releases capacity but does not automatically issue a refund; use the Sales payment contract for that separate decision.
Merchant booking reads expose the linked commerce summary. Staff reads are
mine-only: an unassigned, missing, or cross-tenant booking detail returns the
same neutral 404.
Customer booking actions
Signed-in customer routes carry both x-organization-slug and
CustomerSession:
GET /customer/account/bookingsGET /customer/account/bookings/:idPOST /customer/account/bookings/:id/cancel-requestPOST /customer/account/bookings/:id/reschedule-request
A guest can use the scoped management token instead:
GET /customer/bookings/:tokenPOST /customer/bookings/:token/cancel-requestPOST /customer/bookings/:token/reschedule-request
The server projects current action availability from the booking policy and cutoff. Do not duplicate the policy in the client. Unknown, expired, revoked, and cross-tenant tokens stay neutral. Until it expires or is revoked, a consumed token can still render scanner-safe booking detail with its actions disabled, and retrying the same mutation replays the recorded result without creating another request. Preserve the token through an uncertain response; a different action after consumption fails closed.
A reschedule request must choose a changed start and end and preserve the booked duration. Approval rechecks the new interval and claims it before releasing the old capacity.
Atomic multi-booking groups
Use POST /public/booking-groups when one customer submission must create all
members or none. The booking.group/v1 contract accepts at most 20 submitted
selections, 20 normalized members, and 100 server-expanded capacity anchors.
Generate two independent credentials before create:
x-idempotency-keyfor same-intent retry; andbooking-group-create-recovery-key, a cryptographically randomrplus 25 lowercase alphanumeric characters, for lost-response recovery.
The response's publicRef is correlation only. resumeToken is the
authorizing g proof; retain it server-side and send it as
booking-group-resume-key on reads, cancellation, and
POST /public/booking-groups/:publicRef/checkout.
Drive proof rotation from the response's resumeExpiresAt. A group resume
proof expires after 24 hours even though an approval-first group can remain
live much longer. A signed-in customer can mint a fresh proof with
POST /customer/account/booking-groups/:publicRef/resume. A guest recovers
from a purpose-scoped action token by posting { actionToken } to
/customer/booking-groups/actions/exchange. Use the returned publicRef,
resumeToken, and resumeExpiresAt; do not put the proof in a URL.
Cancellation and aggregate checkout are separate mutation intents. Each also
requires its own x-idempotency-key alongside
booking-group-resume-key. Preserve and reuse that exact mutation key when
retrying an uncertain response; do not reuse the create key or one mutation's
key for a different action.
If the response has agreement.status: "ACTION_REQUIRED", the group remains
OPEN and no booking request or capacity claim exists yet. Complete the
required agreement, recover the exact group's fresh resume proof from the
agreement action, then call
POST /public/booking-groups/:publicRef/request with
booking-group-resume-key. That command reuses and revalidates the original
normalized intent; do not create a replacement group.
Group compatibility is single-posture:
PAY_NOWclaims every member hold atomically before aggregate checkout.APPROVE_THEN_PAYcreates capacity-free requests, then claims all members on approval before payment.APPROVE_PAY_LATERcreates capacity-free requests, claims them on approval, and has no initial checkout or charge.
Checkout creates one aggregate collection phase while each booking member keeps its own immutable allocation and later lifecycle. Mixed retail lines and grouped security-deposit authorizations are rejected rather than approximated.
Retry and conflict posture
- Use a new idempotency key for a new intent and preserve the old key for an uncertain retry.
- Treat
404as neutral; never reveal whether a foreign resource or capability exists. - On
409, refresh service/availability/booking state instead of forcing a stale transition. - On
429, honor retry guidance and back off. - On provider
502/503, keep the existing intent and recover through the same server-authoritative route; do not mint parallel payments or holds.