Quickstart: your first booking
This guide takes you from a fresh API key to a confirmed booking, using
curl. Every call matches the live /v1 surface exactly — copy, paste, and
swap in your own IDs. The full hold → verify → pay → confirm flow is live,
card payment included.
Before you start
Section titled “Before you start”You don’t sign up for API keys yourself. Your operator contact gives you two keys after onboarding:
sk_test_…— your server’s key. Keep it off the client.pk_test_…— safe to put in a browser (see Authentication for why).
Use the _test_ pair while you build. Test-mode keys hit the same API,
against the operator’s sandbox.
1. Check the API is reachable
Section titled “1. Check the API is reachable”curl https://api.<domain>/v1/ping{ "ok": true, "ts": "2026-07-16T03:00:00.000Z" }No key needed for this one call.
2. Check availability
Section titled “2. Check availability”curl "https://api.<domain>/v1/availability?resource_id=<RESOURCE_ID>&from=2026-08-01T00:00:00Z&to=2026-08-04T00:00:00Z&party_size=2" \ -H "Authorization: Bearer sk_test_..."{ "resourceId": "<RESOURCE_ID>", "from": "2026-08-01T00:00:00Z", "to": "2026-08-04T00:00:00Z", "partySize": 2, "available": true, "reasons": []}available: false comes with a non-empty reasons array — min_stay,
stop_sell, reserved, and so on. See Availability
for the full list.
3. Price the stay
Section titled “3. Price the stay”curl -X POST https://api.<domain>/v1/quotes \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "resourceId": "<RESOURCE_ID>", "ratePlanId": "<RATE_PLAN_ID>", "from": "2026-08-01T00:00:00Z", "to": "2026-08-04T00:00:00Z", "partySize": 2 }'Response (trimmed):
{ "quoteId": "...", "displayTotal": { "currency": "IDR", "amountMinorUnits": "4500000" }, "signedQuoteToken": "eyJhbGciOi...", "expiresAt": "2026-07-16T03:15:00.000Z"}ratePlanId is required if the resource has room types — most lodging does.
See Resources, room types, rate plans
for how to look one up. The quote is only valid for 15 minutes: hold the
stay before expiresAt, or re-quote.
Money on the wire is always a string of minor units. Don’t parse
amountMinorUnits with parseFloat — see Quotes.
4. Hold the stay
Section titled “4. Hold the stay”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..." }'{ "id": "<RESERVATION_ID>", "resourceId": "<RESOURCE_ID>", "ratePlanId": "<RATE_PLAN_ID>", "state": "held", "startsAt": "2026-08-01T00:00:00.000Z", "endsAt": "2026-08-04T00:00:00.000Z", "totalMinorUnits": "4500000", "currency": "IDR", "holdExpiresAt": "2026-07-16T03:15:00.000Z", "createdAt": "2026-07-16T03:00:00.000Z"}Idempotency-Key is required, as a header — not a body field. Retry the
same key safely (a timed-out request, a flaky connection) and you get back
the same hold, 200 instead of 201, never a duplicate. partySize in the
body is optional and informational only; the price is locked from the quote,
not recomputed from it.
A hold expires in 15 minutes (holdExpiresAt). signedQuoteToken is the
same one from step 3 — an invalid or expired token is a 400
(invalid_quote_token / quote_expired); a resource that’s busy with
another concurrent hold, or already booked, is a 409
(resource_locked / hold_conflict).
5. Verify the guest
Section titled “5. Verify the guest”Given the reservationId from step 4, request a one-time code:
curl -X POST https://api.<domain>/v1/holds/<RESERVATION_ID>/otp \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "email": "guest@example.com" }'{ "accepted": true, "expiresAt": "2026-07-16T03:10:00.000Z" }The guest receives a 6-digit code by email. Verify it:
curl -X POST https://api.<domain>/v1/holds/<RESERVATION_ID>/otp/verify \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "email": "guest@example.com", "code": "482913" }'{ "guestVerificationToken": "eyJhbGciOi...", "expiresAt": "2026-07-16T03:20:00.000Z"}Six wrong codes in a row locks that code (423) — the guest has to request
a new one. Both calls are rate-limited per hold; a 429 carries
Retry-After. Full failure-mode table:
Verify a guest and confirm a hold.
6. Pay for the stay
Section titled “6. Pay for the stay”Card payment is a real Stripe charge, and confirming a hold requires it. Create a PaymentIntent for the held reservation:
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 '{}'{ "reservationId": "<RESERVATION_ID>", "stripePaymentIntentId": "pi_...", "clientSecret": "pi_..._secret_...", "amountMinorUnits": "4500000", "currency": "IDR", "state": "requires_payment"}This call needs an sk_ key (a pk_ key can’t create a payment intent) and
an Idempotency-Key header, same rule as step 4 — retry the same key
safely and get the same intent back, never a duplicate charge.
Hand clientSecret to Stripe.js or Stripe Elements running in the guest’s
browser, and confirm the card there with the tenant’s Stripe
publishable key — a Stripe-issued key, separate from our own pk_.
Your server never sees the card number.
When the card payment succeeds, Stripe sends payment_intent.succeeded to
our webhook, and the reservation moves from held to confirmed
automatically — no extra API call needed on the payment path. If the
operator hasn’t connected a Stripe account yet, this call returns
409 stripe_not_configured.
7. Confirm the booking
Section titled “7. Confirm the booking”curl -X POST https://api.<domain>/v1/holds/<RESERVATION_ID>/confirm \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "guestVerificationToken": "eyJhbGciOi..." }'{ "id": "<RESERVATION_ID>", "resourceId": "<RESOURCE_ID>", "ratePlanId": "<RATE_PLAN_ID>", "state": "confirmed", "startsAt": "2026-08-01T00:00:00.000Z", "endsAt": "2026-08-04T00:00:00.000Z", "totalMinorUnits": "4500000", "currency": "IDR", "holdExpiresAt": null, "createdAt": "2026-07-16T03:00:00.000Z"}Re-confirming an already-confirmed hold is safe — same response, no error.
A token minted for a different reservation is a 400 wrong_reservation; an
invalid or expired one is 400 invalid_guest_token; a hold that’s no longer
confirmable (already cancelled or expired) is 409 not_confirmable.
This route usually isn’t the one that confirms a paid booking. Once the
guest’s card payment succeeds (step 6), our Stripe webhook confirms the
reservation on its own — you don’t need to call confirm at all on that
path. confirm still exists to finalize a hold once payment is in place: if
you call it before a payment has succeeded, it returns
409 payment_required instead of confirming.
8. Look up the booking
Section titled “8. Look up the booking”GET /v1/bookings/{id} needs an sk_ key — it returns guest data
(guestEmail), which a pk_ key can never read.
curl https://api.<domain>/v1/bookings/<RESERVATION_ID> \ -H "Authorization: Bearer sk_test_..."The response is the same shape as step 6’s, plus guestEmail, source,
occupancyAdults/occupancyChildren, and updatedAt. PATCH drives further
transitions (checked_in, checked_out, no_show, cancelled); DELETE
cancels the booking. Both 404 for an unknown or cross-tenant id, and 409 for
an illegal transition (e.g. cancelling an already-checked-out booking).
What’s next
Section titled “What’s next”- Building a browser checkout instead of a server flow? See Embed the widget — it checks availability today; it doesn’t take a booking end to end yet.
- Prefer TypeScript to raw curl?
packages/sdk(@booking-engine/sdk) is a generated client typed straight off this same spec — see its README for the full availability → quote → hold → pay → confirm flow. It isn’t published to a public registry yet, so pull it from this repo for now. - Want field-by-field detail? See API Reference.
- Something 4xx’d? See Errors.