Holds and confirmation
A reservation moves through one state machine, and only through it — nothing changes a reservation’s state any other way:
HELD → CONFIRMED → CHECKED_INHELD → CANCELLEDHELD → EXPIRED (hold timed out — 15 minutes)CONFIRMED → CANCELLEDCONFIRMED → NO_SHOWCreating a hold
Section titled “Creating a hold”POST /v1/holds takes a signedQuoteToken from Quotes and
turns it into a 15-minute hold.
curl -X POST https://api.<domain>/v1/holds \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: <a-uuid-you-generate>" \ -H "Content-Type: application/json" \ -d '{ "signedQuoteToken": "eyJhbGciOi..." }'const { data: hold, error } = await client.POST("/v1/holds", { headers: { "Idempotency-Key": "<a-uuid-you-generate>" }, body: { signedQuoteToken: quote.signedQuoteToken },});if (error) throw error;A pk_ key can create a hold (it’s the widget’s job); it can never read what’s
inside one — reading a hold’s guest data needs GET /v1/bookings/{id} with
an sk_ key.
Paying for a hold
Section titled “Paying for a hold”POST /v1/holds/{id}/payment-intent creates a Stripe PaymentIntent for a
held reservation. It needs an sk_ key and an Idempotency-Key header.
curl -X POST https://api.<domain>/v1/holds/<RESERVATION_ID>/payment-intent \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: <a-unique-uuid>" \ -H "Content-Type: application/json" \ -d '{}'const { data: paymentIntent, error } = await client.POST( "/v1/holds/{reservationId}/payment-intent", { params: { path: { reservationId: hold.id } }, headers: { "Idempotency-Key": "<a-unique-uuid>" }, },);if (error) throw error;It returns a clientSecret, which the integrator hands to Stripe.js or Stripe
Elements in the guest’s browser; the card is charged there, never on our
servers. If the tenant hasn’t connected a Stripe account, this returns
409 stripe_not_configured.
When the card payment succeeds, Stripe sends payment_intent.succeeded to
our webhook (POST /v1/webhooks/stripe), and the reservation moves
HELD → CONFIRMED automatically. No separate confirm call is needed on
this path.
Confirming a hold
Section titled “Confirming a hold”POST /v1/holds/{id}/confirm exchanges a guest-verification token — see
Verify a guest and confirm a hold —
for a CONFIRMED reservation.
curl -X POST https://api.<domain>/v1/holds/<RESERVATION_ID>/confirm \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "guestVerificationToken": "eyJhbGciOi..." }'const { data: confirmed, error } = await client.POST("/v1/holds/{reservationId}/confirm", { params: { path: { reservationId: hold.id } }, body: { guestVerificationToken: verified.guestVerificationToken },});if (error) throw error;Reading and managing a booking
Section titled “Reading and managing a booking”GET/PATCH/DELETE /v1/bookings/{id} reads a reservation and drives further
legal transitions (check the guest in, check them out, mark a no-show,
cancel). These need an sk_ key — a booking carries guest data, which a
pk_ key can never read. PATCH only accepts checked_in, checked_out,
no_show, or cancelled as a target state — confirmed is reachable only
through the guest-token-gated confirm route above, never a plain PATCH.
Why a pk_ hold isn’t a booking
Section titled “Why a pk_ hold isn’t a booking”Anyone can call POST /v1/holds with a publishable key found in a page’s
source — that key is public by design. The only reason that isn’t a
problem: a hold is worthless inventory-squatting until a real guest
verifies their email and pays. Both of those steps are gated on our
server, never the browser: verification through the OTP token, payment
through the Stripe webhook — a pk_ key can trigger neither on its own.
Refunding a confirmed, paid booking
Section titled “Refunding a confirmed, paid booking”Cancelling a reservation (DELETE /v1/bookings/{id}, above) never moves
money on its own — refunding is a separate call. See
Refund a booking.