Skip to content

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.

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.

  1. 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, cancelNoticeHours and the studio timezone — everything you need to render without a second call.

  2. 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-store for a reason.

  3. const idempotencyKey = crypto.randomUUID(); // once per attempt, kept across retries
    const hold = await post(`/public/sites/${slug}/holds`, {
    roomId, date, startTime, durationMinutes,
    guestName, channel: 'phone', contact: '+34600111222',
    locale: 'es', idempotencyKey, captchaToken,
    });
    • contact is a phone in E.164 (+34…) when channel is phone, or an email address when it is email.
    • 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) and expiresAt. Show a countdown: the slot is yours for 12 minutes.
    • Retrying the same request with the same idempotencyKey returns the same hold instead of a second one. A different payload with a used key is rejected.
  4. 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 you resendsLeft.

  5. const { booking, manageUrl, clientSecret } = await post(`/public/holds/${holdId}/confirm`, {
    paymentMode: 'on_site', // or 'card' — must be in room.paymentModes
    notes, // optional, shown to the studio
    email, // optional second contact for the receipt
    });

    With on_site the booking is confirmed immediately. With card it comes back pending_payment plus a Stripe clientSecret — see Card payments.

  6. Show booking.reference and manageUrl. The guest also receives both by SMS/email. If you built your own cancel screen, keep the manageToken; otherwise let the hosted page do it.

What happenedCodeWhat to do
Someone booked the slot first409 SLOT_TAKENRefresh availability, keep the guest’s details, ask for another time
The 12 minutes ran out410 HOLD_EXPIREDStart again from step 3 with a new idempotency key
Wrong code400 CODE_INVALIDLet them retype; show attempts left
Five wrong codes429 TOO_MANY_ATTEMPTSThe hold is dead — restart
Guest hammering the form429 RATE_LIMIT_EXCEEDEDBack off; 5 holds per contact and 20 per IP per hour
Duration not allowed400 VALIDATION_ERRORRe-derive durations from min/max/stepMinutes
Room closed then409 OUTSIDE_HOURSYour span maths drifted — refetch availability
Too soon / too far ahead409 OUTSIDE_BOOKING_WINDOWRespect minNoticeMin and maxAdvanceDays
Studio turned the site off mid-flow404 SITE_NOT_LIVEShow the studio’s phone/WhatsApp from the site payload

Full list: Errors & rate limits.

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