Quickstart: your first booking
This guide takes you from a fresh API key to a confirmed booking. 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.
Examples are shown in curl and in TypeScript, using
@booking-engine/sdk — a typed client generated from this same API spec.
Pick whichever tab matches how you’re integrating.
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/pingconst { data } = await client.GET("/v1/ping");console.log(data); // { ok: true, ts: "..." }{ "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_..."const { data, error } = await client.GET("/v1/availability", { params: { query: { resource_id: "<RESOURCE_ID>", from: "2026-08-01T00:00:00Z", to: "2026-08-04T00:00:00Z", party_size: 2, }, },});if (error) throw error;{ "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 }'const { data: quote, error } = await client.POST("/v1/quotes", { body: { resourceId: "<RESOURCE_ID>", ratePlanId: "<RATE_PLAN_ID>", from: "2026-08-01T00:00:00Z", to: "2026-08-04T00:00:00Z", partySize: 2, },});if (error) throw error;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.
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..." }'const { data: hold, error } = await client.POST("/v1/holds", { headers: { "Idempotency-Key": "<a-uuid-you-generate>" }, body: { signedQuoteToken: quote.signedQuoteToken },});if (error) throw error;{ "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"}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" }'const { data: otp, error } = await client.POST("/v1/holds/{reservationId}/otp", { params: { path: { reservationId: hold.id } }, body: { email: "guest@example.com" },});if (error) throw error;{ "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" }'const { data: verified, error } = await client.POST("/v1/holds/{reservationId}/otp/verify", { params: { path: { reservationId: hold.id } }, body: { email: "guest@example.com", code: "482913" },});if (error) throw error;{ "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 '{}'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;{ "reservationId": "<RESERVATION_ID>", "stripePaymentIntentId": "pi_...", "clientSecret": "pi_..._secret_...", "amountMinorUnits": "4500000", "currency": "IDR", "state": "requires_payment"}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..." }'const { data: confirmed, error } = await client.POST("/v1/holds/{reservationId}/confirm", { params: { path: { reservationId: hold.id } }, body: { guestVerificationToken: verified.guestVerificationToken },});if (error) throw error;{ "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.
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_..."const { data: booking, error } = await client.GET("/v1/bookings/{reservationId}", { params: { path: { reservationId: hold.id } },});if (error) throw error;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).
Next steps
Section titled “Next steps”- Building a browser checkout instead of a server flow? See Embed the widget — the same availability → quote → hold → pay flow, pre-built as a copy-paste embed, using a Stripe-hosted checkout page instead of Stripe Elements.
- Just want an availability calendar on a page, no booking flow? See Embed the calendar.
- Need to send money back after a booking is cancelled or changed? See Refund a booking.
- Want field-by-field detail? See API Reference.
- Something 4xx’d? See Errors.