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, Node, PHP, Python, and
TypeScript (using @booking-engine/sdk, a typed client generated from
this same API spec). Pick whichever tab matches how you’re integrating.
Every one of these calls runs on your server with a secret (sk_) key —
never put an sk_ key in a browser. For a browser, use the
widget with a pk_ key.
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. The dashboard issues live keys only, so ask
your operator contact to get test keys for you.
1. Check the API is reachable
Section titled “1. Check the API is reachable”curl https://api.<domain>/v1/ping// Node 18+. Keep BOOKING_ENGINE_API_KEY (sk_) on the server.const BASE = "https://api.<domain>";
const res = await fetch(`${BASE}/v1/ping`);console.log(await res.json());<?php// Keep BOOKING_ENGINE_API_KEY (sk_) on the server.$ch = curl_init("https://api.<domain>/v1/ping");curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true,]);$body = json_decode(curl_exec($ch), true);curl_close($ch);# Keep BOOKING_ENGINE_API_KEY (sk_) on the server.import requests
res = requests.get( "https://api.<domain>/v1/ping",)res.raise_for_status()print(res.json())const { 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_..."// Node 18+. Keep BOOKING_ENGINE_API_KEY (sk_) on the server.const BASE = "https://api.<domain>";
const res = await fetch(`${BASE}/v1/availability?resource_id=<RESOURCE_ID>&from=2026-08-01T00:00:00Z&to=2026-08-04T00:00:00Z&party_size=2`, { headers: { Authorization: `Bearer ${process.env.BOOKING_ENGINE_API_KEY}`, },});console.log(await res.json());<?php// Keep BOOKING_ENGINE_API_KEY (sk_) on the server.$ch = curl_init("https://api.<domain>/v1/availability?resource_id=<RESOURCE_ID>&from=2026-08-01T00:00:00Z&to=2026-08-04T00:00:00Z&party_size=2");curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BOOKING_ENGINE_API_KEY"), ],]);$body = json_decode(curl_exec($ch), true);curl_close($ch);# Keep BOOKING_ENGINE_API_KEY (sk_) on the server.import osimport requests
res = requests.get( "https://api.<domain>/v1/availability", params={"resource_id": "<RESOURCE_ID>", "from": "2026-08-01T00:00:00Z", "to": "2026-08-04T00:00:00Z", "party_size": "2"}, headers={"Authorization": f"Bearer {os.environ['BOOKING_ENGINE_API_KEY']}"},)res.raise_for_status()print(res.json())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 }'// Node 18+. Keep BOOKING_ENGINE_API_KEY (sk_) on the server.const BASE = "https://api.<domain>";
const res = await fetch(`${BASE}/v1/quotes`, { method: "POST", headers: { Authorization: `Bearer ${process.env.BOOKING_ENGINE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({"resourceId": "<RESOURCE_ID>", "ratePlanId": "<RATE_PLAN_ID>", "from": "2026-08-01T00:00:00Z", "to": "2026-08-04T00:00:00Z", "partySize": 2}),});console.log(await res.json());<?php// Keep BOOKING_ENGINE_API_KEY (sk_) on the server.$ch = curl_init("https://api.<domain>/v1/quotes");curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BOOKING_ENGINE_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{"resourceId": "<RESOURCE_ID>", "ratePlanId": "<RATE_PLAN_ID>", "from": "2026-08-01T00:00:00Z", "to": "2026-08-04T00:00:00Z", "partySize": 2}',]);$body = json_decode(curl_exec($ch), true);curl_close($ch);# Keep BOOKING_ENGINE_API_KEY (sk_) on the server.import osimport requests
res = requests.post( "https://api.<domain>/v1/quotes", headers={"Authorization": f"Bearer {os.environ['BOOKING_ENGINE_API_KEY']}"}, json={"resourceId": "<RESOURCE_ID>", "ratePlanId": "<RATE_PLAN_ID>", "from": "2026-08-01T00:00:00Z", "to": "2026-08-04T00:00:00Z", "partySize": 2},)res.raise_for_status()print(res.json())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..." }'// Node 18+. Keep BOOKING_ENGINE_API_KEY (sk_) on the server.const BASE = "https://api.<domain>";
const res = await fetch(`${BASE}/v1/holds`, { method: "POST", headers: { Authorization: `Bearer ${process.env.BOOKING_ENGINE_API_KEY}`, "Idempotency-Key": crypto.randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({"signedQuoteToken": "eyJhbGciOi..."}),});console.log(await res.json());<?php// Keep BOOKING_ENGINE_API_KEY (sk_) on the server.$ch = curl_init("https://api.<domain>/v1/holds");curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BOOKING_ENGINE_API_KEY"), "Idempotency-Key: " . bin2hex(random_bytes(16)), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{"signedQuoteToken": "eyJhbGciOi..."}',]);$body = json_decode(curl_exec($ch), true);curl_close($ch);# Keep BOOKING_ENGINE_API_KEY (sk_) on the server.import os, uuidimport requests
res = requests.post( "https://api.<domain>/v1/holds", headers={"Authorization": f"Bearer {os.environ['BOOKING_ENGINE_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, json={"signedQuoteToken": "eyJhbGciOi..."},)res.raise_for_status()print(res.json())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"}You can also send the guest’s contact in a guest object
({ "name", "email", "phone" }, with phone optional). We save it on the
booking. It is optional with an sk_ key, and required with a pk_ key for
an hourly space. See
Holds and confirmation.
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). resource_locked carries
Retry-After: wait that many seconds and retry with the same key.
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" }'// Node 18+. Keep BOOKING_ENGINE_API_KEY (sk_) on the server.const BASE = "https://api.<domain>";
const res = await fetch(`${BASE}/v1/holds/<RESERVATION_ID>/otp`, { method: "POST", headers: { Authorization: `Bearer ${process.env.BOOKING_ENGINE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({"email": "guest@example.com"}),});console.log(await res.json());<?php// Keep BOOKING_ENGINE_API_KEY (sk_) on the server.$ch = curl_init("https://api.<domain>/v1/holds/<RESERVATION_ID>/otp");curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BOOKING_ENGINE_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{"email": "guest@example.com"}',]);$body = json_decode(curl_exec($ch), true);curl_close($ch);# Keep BOOKING_ENGINE_API_KEY (sk_) on the server.import osimport requests
res = requests.post( "https://api.<domain>/v1/holds/<RESERVATION_ID>/otp", headers={"Authorization": f"Bearer {os.environ['BOOKING_ENGINE_API_KEY']}"}, json={"email": "guest@example.com"},)res.raise_for_status()print(res.json())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" }'// Node 18+. Keep BOOKING_ENGINE_API_KEY (sk_) on the server.const BASE = "https://api.<domain>";
const res = await fetch(`${BASE}/v1/holds/<RESERVATION_ID>/otp/verify`, { method: "POST", headers: { Authorization: `Bearer ${process.env.BOOKING_ENGINE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({"email": "guest@example.com", "code": "482913"}),});console.log(await res.json());<?php// Keep BOOKING_ENGINE_API_KEY (sk_) on the server.$ch = curl_init("https://api.<domain>/v1/holds/<RESERVATION_ID>/otp/verify");curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BOOKING_ENGINE_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{"email": "guest@example.com", "code": "482913"}',]);$body = json_decode(curl_exec($ch), true);curl_close($ch);# Keep BOOKING_ENGINE_API_KEY (sk_) on the server.import osimport requests
res = requests.post( "https://api.<domain>/v1/holds/<RESERVATION_ID>/otp/verify", headers={"Authorization": f"Bearer {os.environ['BOOKING_ENGINE_API_KEY']}"}, json={"email": "guest@example.com", "code": "482913"},)res.raise_for_status()print(res.json())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"}A code works for 10 minutes. Five wrong guesses lock it, so the guest has to
request a new one. A wrong, expired, or locked code all return the same
400 bad_code. 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 '{}'// Node 18+. Keep BOOKING_ENGINE_API_KEY (sk_) on the server.const BASE = "https://api.<domain>";
const res = await fetch(`${BASE}/v1/holds/<RESERVATION_ID>/payment-intent`, { method: "POST", headers: { Authorization: `Bearer ${process.env.BOOKING_ENGINE_API_KEY}`, "Idempotency-Key": crypto.randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({}),});console.log(await res.json());<?php// Keep BOOKING_ENGINE_API_KEY (sk_) on the server.$ch = curl_init("https://api.<domain>/v1/holds/<RESERVATION_ID>/payment-intent");curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BOOKING_ENGINE_API_KEY"), "Idempotency-Key: " . bin2hex(random_bytes(16)), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{}',]);$body = json_decode(curl_exec($ch), true);curl_close($ch);# Keep BOOKING_ENGINE_API_KEY (sk_) on the server.import os, uuidimport requests
res = requests.post( "https://api.<domain>/v1/holds/<RESERVATION_ID>/payment-intent", headers={"Authorization": f"Bearer {os.environ['BOOKING_ENGINE_API_KEY']}", "Idempotency-Key": str(uuid.uuid4())}, json={},)res.raise_for_status()print(res.json())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..." }'// Node 18+. Keep BOOKING_ENGINE_API_KEY (sk_) on the server.const BASE = "https://api.<domain>";
const res = await fetch(`${BASE}/v1/holds/<RESERVATION_ID>/confirm`, { method: "POST", headers: { Authorization: `Bearer ${process.env.BOOKING_ENGINE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({"guestVerificationToken": "eyJhbGciOi..."}),});console.log(await res.json());<?php// Keep BOOKING_ENGINE_API_KEY (sk_) on the server.$ch = curl_init("https://api.<domain>/v1/holds/<RESERVATION_ID>/confirm");curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BOOKING_ENGINE_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => '{"guestVerificationToken": "eyJhbGciOi..."}',]);$body = json_decode(curl_exec($ch), true);curl_close($ch);# Keep BOOKING_ENGINE_API_KEY (sk_) on the server.import osimport requests
res = requests.post( "https://api.<domain>/v1/holds/<RESERVATION_ID>/confirm", headers={"Authorization": f"Bearer {os.environ['BOOKING_ENGINE_API_KEY']}"}, json={"guestVerificationToken": "eyJhbGciOi..."},)res.raise_for_status()print(res.json())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, guestName, guestPhone), which a pk_ key can never read.
curl https://api.<domain>/v1/bookings/<RESERVATION_ID> \ -H "Authorization: Bearer sk_test_..."// Node 18+. Keep BOOKING_ENGINE_API_KEY (sk_) on the server.const BASE = "https://api.<domain>";
const res = await fetch(`${BASE}/v1/bookings/<RESERVATION_ID>`, { headers: { Authorization: `Bearer ${process.env.BOOKING_ENGINE_API_KEY}`, },});console.log(await res.json());<?php// Keep BOOKING_ENGINE_API_KEY (sk_) on the server.$ch = curl_init("https://api.<domain>/v1/bookings/<RESERVATION_ID>");curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("BOOKING_ENGINE_API_KEY"), ],]);$body = json_decode(curl_exec($ch), true);curl_close($ch);# Keep BOOKING_ENGINE_API_KEY (sk_) on the server.import osimport requests
res = requests.get( "https://api.<domain>/v1/bookings/<RESERVATION_ID>", headers={"Authorization": f"Bearer {os.environ['BOOKING_ENGINE_API_KEY']}"},)res.raise_for_status()print(res.json())const { data: booking, error } = await client.GET("/v1/bookings/{reservationId}", { params: { path: { reservationId: hold.id } },});if (error) throw error;The response has the hold fields from step 4, plus the guest’s contact and a few more fields:
{ "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", "roomTypeId": "<ROOM_TYPE_ID>", "occupancyAdults": null, "occupancyChildren": 0, "quantity": 1, "source": "api", "guestEmail": "guest@example.com", "guestName": null, "guestPhone": null, "updatedAt": "2026-07-16T03:05:00.000Z"}guestEmail is the email the guest verified in step 5. guestName and
guestPhone are null here, because step 4 sent no guest object. If you
send guest with the hold, they hold the name and phone you sent.
occupancyAdults is null because step 4 sent no partySize.
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).
When the booking becomes confirmed, the guest gets a “Booking confirmed”
email at the saved email address.
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.
- Selling meeting rooms, desks, or services by the hour? See Hourly spaces and services.
- Want your server told when a booking changes, with no polling? See Webhooks.
- 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.