Skip to content

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_OUT
HELD → CANCELLED
HELD → EXPIRED (hold timed out — 15 minutes)
CONFIRMED → CANCELLED
CONFIRMED → NO_SHOW
CHECKED_IN → CANCELLED

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.

Terminal window
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..." }'

Send the booker’s contact in guest. We save it on the booking so the operator can reach the guest.

Terminal window
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"
}
}'
  • name is 1 to 120 characters. email is a valid email, up to 254 characters. We trim it and make it lowercase. phone is optional: 6 to 20 characters, digits and spaces, with an optional + at the start.
  • With a pk_ key, guest is required for an hourly space. Without it the call returns 400 guest_required. It is optional for hotel stays, and optional with an sk_ key.
  • The email is not checked at this step. Guest verification proves it later, and the verified email replaces this one.
  • We never return guest in a response to a pk_ key. Not even the hold response has it.

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.

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.

Terminal window
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" }'
ResultWhen
200The booking is now confirmed. Also returned if you send the same call again.
404 booking_not_foundNo such booking for your account.
409 not_confirmableThe 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.

POST /v1/holds/{id}/payment-intent creates a Stripe PaymentIntent for a held reservation. It needs an sk_ key and an Idempotency-Key header.

Terminal window
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 '{}'

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.

POST /v1/holds/{id}/confirm exchanges a guest-verification token — see Verify a guest and confirm a hold — for a CONFIRMED reservation.

Terminal window
curl -X POST https://api.<domain>/v1/holds/<RESERVATION_ID>/confirm \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "guestVerificationToken": "eyJhbGciOi..." }'

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.

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.

Cancelling a reservation (DELETE /v1/bookings/{id}, above) never moves money on its own — refunding is a separate call. See Refund a booking.