Skip to content

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.

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.

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"
}

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"
}

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.

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