Build your own booking UI
You render everything; the API owns prices, policy and conflicts. This page is the whole flow, including what to do when it goes wrong.
The state you need
Section titled “The state you need”type Flow = | { step: 'slot' } // picking day + time | { step: 'details' } // name + phone/email | { step: 'verify'; holdId: string; expiresAt: string } | { step: 'pay'; holdId: string } // only if the room takes cards | { step: 'done'; reference: string; manageUrl: string };Keep holdId, expiresAt and the idempotencyKey you generated. Nothing else needs to survive a reload.
-
Load the site once
Section titled “Load the site once”const site = await get(`/public/sites/${slug}`);const rooms = site.rooms.filter((r) => r.bookable);Cache it for a minute. It gives you names, photos, prices,
paymentModes,depositPercent,cancelNoticeHoursand the studiotimezone— everything you need to render without a second call. -
Turn free spans into start times
Section titled “Turn free spans into start times”Availability returns continuous spans, not slots, so you choose the granularity:
const toMin = (hhmm: string) => +hhmm.slice(0, 2) * 60 + +hhmm.slice(3, 5);const toHHMM = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;/** Every start time where a booking of `duration` fits inside a free span. */function startTimes(free: { start: string; end: string }[], duration: number, step: number) {const out: string[] = [];for (const span of free) {const s = toMin(span.start), e = toMin(span.end);for (let t = Math.ceil(s / step) * step; t + duration <= e; t += step) out.push(toHHMM(t));}return out;}Ask for a window you can show at once (a week, a month — up to 31 days) and re-fetch when the guest moves the calendar. Never cache availability: it is
no-storefor a reason. -
Create the hold
Section titled “Create the hold”const idempotencyKey = crypto.randomUUID(); // once per attempt, kept across retriesconst hold = await post(`/public/sites/${slug}/holds`, {roomId, date, startTime, durationMinutes,guestName, channel: 'phone', contact: '+34600111222',locale: 'es', idempotencyKey, captchaToken,});contactis a phone in E.164 (+34…) whenchannelisphone, or an email address when it isemail.locale(es·ca·en) picks the language of the SMS/email the guest receives.- The response carries the server’s price (
price.priceCents,price.depositCents) andexpiresAt. Show a countdown: the slot is yours for 12 minutes. - Retrying the same request with the same
idempotencyKeyreturns the same hold instead of a second one. A different payload with a used key is rejected.
-
Verify the code
Section titled “Verify the code”await post(`/public/holds/${holdId}/verify`, { code });Six digits, valid 10 minutes. Five wrong attempts burn the hold (
429 TOO_MANY_ATTEMPTS→ start over). Offer a resend after 60 s; there are 3 sends in total, and the response tells youresendsLeft. -
Confirm
Section titled “Confirm”const { booking, manageUrl, clientSecret } = await post(`/public/holds/${holdId}/confirm`, {paymentMode: 'on_site', // or 'card' — must be in room.paymentModesnotes, // optional, shown to the studioemail, // optional second contact for the receipt});With
on_sitethe booking isconfirmedimmediately. Withcardit comes backpending_paymentplus a StripeclientSecret— see Card payments. -
Hand over the manage link
Section titled “Hand over the manage link”Show
booking.referenceandmanageUrl. The guest also receives both by SMS/email. If you built your own cancel screen, keep themanageToken; otherwise let the hosted page do it.
Failure cases worth handling
Section titled “Failure cases worth handling”| What happened | Code | What to do |
|---|---|---|
| Someone booked the slot first | 409 SLOT_TAKEN | Refresh availability, keep the guest’s details, ask for another time |
| The 12 minutes ran out | 410 HOLD_EXPIRED | Start again from step 3 with a new idempotency key |
| Wrong code | 400 CODE_INVALID | Let them retype; show attempts left |
| Five wrong codes | 429 TOO_MANY_ATTEMPTS | The hold is dead — restart |
| Guest hammering the form | 429 RATE_LIMIT_EXCEEDED | Back off; 5 holds per contact and 20 per IP per hour |
| Duration not allowed | 400 VALIDATION_ERROR | Re-derive durations from min/max/stepMinutes |
| Room closed then | 409 OUTSIDE_HOURS | Your span maths drifted — refetch availability |
| Too soon / too far ahead | 409 OUTSIDE_BOOKING_WINDOW | Respect minNoticeMin and maxAdvanceDays |
| Studio turned the site off mid-flow | 404 SITE_NOT_LIVE | Show the studio’s phone/WhatsApp from the site payload |
Full list: Errors & rate limits.
A reference implementation
Section titled “A reference implementation”Our own hosted booking screen is exactly this flow. If something is ambiguous, compare against it:
- Open any live studio’s
/reservar/<roomId>page and watch the network tab. - Every call it makes is one of the eight public endpoints documented here.
Checklist before going live
Section titled “Checklist before going live”- Prices come from the API (
price.priceCents), never from your own multiplication. - Durations are derived from
minMinutes/maxMinutes/stepMinutes. - The studio’s time zone is shown next to every time.
- A countdown shows the hold expiry, and expiry restarts the flow cleanly.
- One idempotency key per attempt, reused on network retries.
- A captcha token is attached in production (Turnstile).
- The guest’s IP reaches us (
X-Forwarded-For) if you proxy through a server. - The guest ends on a screen with the reference and the manage link.