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_IN → CHECKED_OUTHELD → CANCELLEDHELD → EXPIRED (hold timed out — 15 minutes)CONFIRMED → CANCELLEDCONFIRMED → NO_SHOWCHECKED_IN → CANCELLEDCreating a hold
Section titled “Creating a hold”POST /v1/holds takes a signedQuoteToken from Quotes and
turns it into a 15-minute hold. It works for a hotel stay and for an hourly
slot.
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;Add the guest’s contact
Section titled “Add the guest’s contact”Send the booker’s contact in guest. We save it on the booking so the
operator can reach the guest.
curl -X POST https://api.<domain>/v1/holds \ -H "Authorization: Bearer pk_test_..." \ -H "Idempotency-Key: <a-uuid-you-generate>" \ -H "Content-Type: application/json" \ -d '{ "signedQuoteToken": "eyJhbGciOi...", "guest": { "name": "Sari Dewi", "email": "sari@example.com", "phone": "+62 812 3456 7890" } }'nameis 1 to 120 characters.emailis a valid email, up to 254 characters. We trim it and make it lowercase.phoneis optional: 6 to 20 characters, digits and spaces, with an optional+at the start.- With a
pk_key,guestis required for an hourly space. Without it the call returns400 guest_required. It is optional for hotel stays, and optional with ansk_key. - The email is not checked at this step. Guest verification proves it later, and the verified email replaces this one.
- We never return
guestin a response to apk_key. Not even the hold response has it.
Who can read a hold
Section titled “Who can read a hold”A pk_ key can create a hold (it’s the widget’s job); it can never read
what’s inside one. The guest’s contact comes back as guestEmail,
guestName, and guestPhone on every /v1/bookings response, which needs an
sk_ key. A pk_ key gets 404.
A hold has two caps for pk_ keys: 2 active holds per key and IP address, and
50 per tenant. Over a cap you get 429 active_hold_limit_reached. See
Rate limits.
Confirming a hold that was paid offline
Section titled “Confirming a hold that was paid offline”Some guests pay in cash or by bank transfer. Only you know the money arrived,
so you confirm the hold yourself with POST /v1/bookings/{id}/confirm. It
needs an sk_ key with bookings:write, or a dashboard admin session. A
pk_ key gets 404.
curl -X POST https://api.<domain>/v1/bookings/<RESERVATION_ID>/confirm \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "paidOffline": true, "note": "Bank transfer, ref 4821" }'const { data: booking, error } = await client.POST("/v1/bookings/{bookingId}/confirm", { params: { path: { bookingId: reservationId } }, body: { paidOffline: true, note: "Bank transfer, ref 4821" },});if (error) throw error;| Result | When |
|---|---|
200 | The booking is now confirmed. Also returned if you send the same call again. |
404 booking_not_found | No such booking for your account. |
409 not_confirmable | The booking is not held, or its hold has expired, or an online payment already confirmed it. detail names the current state. |
The reservation.confirmed webhook is sent once. The note is saved in the
audit trail, not on the booking. The guest gets a “Booking confirmed” email
at the email address saved on the booking. See Webhooks.
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 a confirm route — the guest-token confirm, the paid-offline confirm,
or a succeeded payment — never a plain PATCH.
Every booking response has these fields, on top of the hold fields:
roomTypeId, occupancyAdults, occupancyChildren, quantity, source,
guestEmail, guestName, guestPhone, and updatedAt. guestEmail,
guestName, and guestPhone are null when not given.
GET /v1/bookings lists bookings. Filter with state, from, to, and
resourceId. It is cursor-paginated: pass cursor from the previous
nextCursor.
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, or the operator confirms it as paid offline.
Each of those steps is gated on our server, never the browser: verification
through the OTP token, payment through the Stripe webhook, and the offline
confirm through an sk_ key or a dashboard admin — a pk_ key can trigger
none of them 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.