Skip to content

Booking availability, holds & lifecycle

The booking API keeps three boundaries separate:

  1. Availability search is an on-demand answer, not a reservation.
  2. A hold transactionally claims finite capacity for a short period.
  3. 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, or FIXED_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:

flowMeaning
PAYMENTA booking and canonical Sales record exist in PENDING_PAYMENT; checkoutToken is a raw c capability for the checkout-session flow.
REQUESTA capacity-free REQUESTED booking awaits merchant action. No checkout token exists.
CONFIRMEDThe booking is already confirmed, including an eligible zero-due result.
TERMINALThe 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/bookings
  • GET /customer/account/bookings/:id
  • POST /customer/account/bookings/:id/cancel-request
  • POST /customer/account/bookings/:id/reschedule-request

A guest can use the scoped management token instead:

  • GET /customer/bookings/:token
  • POST /customer/bookings/:token/cancel-request
  • POST /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-key for same-intent retry; and
  • booking-group-create-recovery-key, a cryptographically random r plus 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_NOW claims every member hold atomically before aggregate checkout.
  • APPROVE_THEN_PAY creates capacity-free requests, then claims all members on approval before payment.
  • APPROVE_PAY_LATER creates 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 404 as 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.