Skip to content

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.

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.

Terminal window
curl https://api.<domain>/v1/ping
{ "ok": true, "ts": "2026-07-16T03:00:00.000Z" }

No key needed for this one call.

Terminal window
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.

Terminal window
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.

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..." }'
{
"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).

Given the reservationId from step 4, request a one-time code:

Terminal window
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:

Terminal window
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.

Card payment is a real Stripe charge, and confirming a hold requires it. Create a PaymentIntent for the held reservation:

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

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

GET /v1/bookings/{id} needs an sk_ key — it returns guest data (guestEmail), which a pk_ key can never read.

Terminal window
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).

  • 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.