Plain JavaScript

No React, no bundler, no SDK: one HTML file that loads a booking page, shows the open times for a day, asks the host's questions and books. Use it on a static site, inside a CMS that accepts custom HTML, or as a reference for porting the flow to Vue, Svelte or anything else.

If you'd rather not write the form at all, the embed script drops clndr.pro's own booking page onto any site with two lines.

The file

Save this as book.html, put your publishable key and slug in the three constants at the top of the script, and open it in a browser. It talks to the API directly; there's no server of yours involved.

book.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Book a call</title>
    <style>
      body { font: 16px/1.5 system-ui, sans-serif; margin: 0; color: #18181b; }
      main { max-width: 34rem; margin: 3rem auto; padding: 0 1rem; display: grid; gap: 1rem; }
      h1 { margin: 0; font-size: 1.5rem; }
      .muted { color: #71717a; font-size: 0.875rem; margin: 0; }
      .description { white-space: pre-line; }
      label { display: grid; gap: 0.25rem; font-size: 0.875rem; font-weight: 500; }
      input, select, textarea { font: inherit; padding: 0.5rem 0.75rem; border: 1px solid #d4d4d8; border-radius: 8px; }
      #slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr)); gap: 0.5rem; }
      #slots button, form button { font: inherit; padding: 0.5rem; border: 1px solid #d4d4d8; border-radius: 8px; background: #fff; cursor: pointer; }
      #slots button[aria-pressed="true"] { border-color: #2563eb; color: #2563eb; }
      form { display: grid; gap: 0.75rem; }
      form button[type="submit"] { background: #18181b; color: #fff; border-color: #18181b; }
      .check { display: flex; gap: 0.5rem; align-items: center; }
      #status:empty { display: none; }
    </style>
  </head>
  <body>
    <main>
      <div>
        <h1 id="title">Loading…</h1>
        <p class="muted" id="meta"></p>
      </div>
      <p class="description" id="description"></p>
 
      <label>
        Day
        <input id="day" type="date" disabled />
      </label>
      <div id="slots" role="group" aria-label="Open times"></div>
 
      <form id="details" hidden>
        <p class="muted" id="picked"></p>
        <label>Name <input name="name" required autocomplete="name" /></label>
        <label>Email <input name="email" type="email" required autocomplete="email" /></label>
        <div id="questions"></div>
        <button type="submit">Book</button>
      </form>
 
      <p id="status" role="status"></p>
    </main>
 
    <script type="module">
      const API = 'https://www.clndr.pro/api/v1'; // always www
      const KEY = 'clndr_pk_...'; // a publishable key: fine in page source
      const SLUG = 'intro-call';
 
      const $ = (id) => document.getElementById(id);
      const time = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' });
      const longDate = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
 
      async function api(path, body) {
        const res = await fetch(API + path, {
          method: body ? 'POST' : 'GET',
          headers: { Authorization: `Bearer ${KEY}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
          body: body ? JSON.stringify(body) : undefined,
        });
        const json = await res.json().catch(() => ({}));
        if (!res.ok) throw Object.assign(new Error(json.error ?? `HTTP ${res.status}`), { status: res.status });
        return json.data;
      }
 
      const ymd = (d) =>
        `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
 
      let page; // { bookingPage, userProfile, questions }
      let picked = null; // { start, end }
 
      // 1. The booking page: title, host, description, questions.
      try {
        page = await api(`/booking-pages/${encodeURIComponent(SLUG)}`);
      } catch (err) {
        $('title').textContent = 'This booking page isn’t available';
        $('meta').textContent = err.message;
        throw err;
      }
 
      const { bookingPage, userProfile, questions } = page;
      document.title = bookingPage.title;
      $('title').textContent = bookingPage.title;
      $('meta').textContent = `${userProfile.full_name ?? userProfile.username} · ${bookingPage.duration_minutes} min`;
      // Markdown from the host: shown as text. Use a Markdown library to render it.
      $('description').textContent = bookingPage.description ?? '';
 
      const today = new Date();
      $('day').min = ymd(today);
      if (bookingPage.max_days_ahead) {
        $('day').max = ymd(new Date(today.getFullYear(), today.getMonth(), today.getDate() + bookingPage.max_days_ahead));
      }
      $('day').disabled = false;
 
      // 2. The host's questions, built with DOM APIs (never innerHTML with host text).
      for (const q of questions) {
        const label = document.createElement('label');
        let field;
        if (q.question_type === 'textarea') {
          field = document.createElement('textarea');
          field.rows = 4;
        } else if (q.question_type === 'select') {
          field = document.createElement('select');
          field.append(new Option('Choose one', ''), ...(q.options ?? []).map((o) => new Option(o, o)));
        } else {
          field = document.createElement('input');
          field.type = { email: 'email', phone: 'tel', checkbox: 'checkbox' }[q.question_type] ?? 'text';
          if (q.question_type === 'checkbox') label.className = 'check';
        }
        field.name = `q:${q.id}`;
        field.required = !!q.is_required;
        label.append(`${q.question_text}${q.is_required ? ' *' : ''}`, field);
        $('questions').append(label);
      }
 
      // 3. Open times for the picked day. The API reads YYYY-MM-DD as the host's day.
      async function loadSlots() {
        picked = null;
        $('details').hidden = true;
        $('slots').replaceChildren();
        if (!$('day').value) return;
        $('status').textContent = 'Loading times…';
        try {
          const slots = await api(`/booking-pages/${encodeURIComponent(SLUG)}/slots?date=${$('day').value}`);
          $('status').textContent = slots.length ? '' : 'No open times that day. Try another one.';
          for (const slot of slots) {
            const button = document.createElement('button');
            button.type = 'button';
            button.textContent = time.format(new Date(slot.start)); // the guest's local time
            button.setAttribute('aria-pressed', 'false');
            button.addEventListener('click', () => pick(slot, button));
            $('slots').append(button);
          }
        } catch (err) {
          $('status').textContent = err.message;
        }
      }
 
      function pick(slot, button) {
        picked = slot;
        for (const b of $('slots').children) b.setAttribute('aria-pressed', String(b === button));
        $('picked').textContent = `${longDate.format(new Date(slot.start))}, ${time.format(new Date(slot.start))}`;
        $('details').hidden = false;
      }
 
      $('day').addEventListener('change', loadSlots);
 
      // 4. Book it.
      $('details').addEventListener('submit', async (event) => {
        event.preventDefault();
        if (!picked) return;
        const form = new FormData(event.currentTarget);
        const responses = questions
          .map((q) => {
            const answer =
              q.question_type === 'checkbox' ? (form.get(`q:${q.id}`) ? 'Yes' : 'No') : String(form.get(`q:${q.id}`) ?? '').trim();
            return { questionId: q.id, answer };
          })
          .filter((r) => r.answer !== '');
 
        const submit = event.currentTarget.querySelector('[type="submit"]');
        submit.disabled = true;
        $('status').textContent = 'Booking…';
        try {
          const booking = await api('/bookings', {
            bookingPageId: bookingPage.id,
            guestName: String(form.get('name')),
            guestEmail: String(form.get('email')),
            startTime: picked.start,
            endTime: picked.end,
            responses,
          });
          $('details').hidden = true;
          $('slots').replaceChildren();
          $('status').textContent =
            booking.status === 'pending'
              ? `Request sent. ${userProfile.full_name ?? userProfile.username} will confirm by email.`
              : `You're booked. A confirmation is on its way to ${booking.guest_email}.`;
        } catch (err) {
          if (/no longer available|not available/i.test(err.message) || err.status === 409) {
            await loadSlots();
            $('status').textContent = 'Someone booked that time a moment ago. Pick another one.';
          } else {
            $('status').textContent = err.message;
          }
        } finally {
          submit.disabled = false;
        }
      });
    </script>
  </body>
</html>

How it works

Requests. api() sends the key as Authorization: Bearer clndr_pk_… and unwraps the data field every successful response has. On failure it throws an Error carrying the API's own error message and the HTTP status. The API sends Access-Control-Allow-Origin: *, so this works from any origin, file:// pages included.

The day. <input type="date"> gives YYYY-MM-DD, and the API reads that as a calendar day in the host's timezone. Pass the string as it is. The times that come back are UTC timestamps, and Intl.DateTimeFormat with an undefined locale formats them in the guest's own locale and timezone. Today's past times and days beyond the page's booking window come back empty, and the min/max on the input keep guests inside that window.

Questions. Each question_type gets the closest native input: textarea, a select built from options, a checkbox (answered "Yes" or "No"), tel for phone, email, or text. Required questions use the browser's required validation, which matters: the API accepts bookings with unanswered questions. Every piece of text from the host goes in through textContent, never innerHTML.

Booking. The POST /bookings body uses camelCase (bookingPageId, guestEmail, startTime) while responses use snake_case (guest_email). startTime and endTime are the slot's start and end, untouched. The response's status is confirmed for direct pages and pending for approval pages, and the message changes to match.

A taken slot. If another guest booked the same time first, the API answers 409. With a publishable key the message is usually "This time slot is not available. Fetch the slots again and pick another." The script treats any 409 as a taken slot, reloads that day's times and asks the guest to pick again.

Making it yours

  • Styling. The <style> block is the whole design; replace it with your site's.
  • The description is Markdown, shown here as plain text with line breaks kept. To render it, run it through a Markdown library such as marked plus a sanitiser such as DOMPurify, since the host's text then becomes HTML.
  • Several booking pages. GET /booking-pages lists every page on the key's account, including inactive and private ones. Filter on is_active and visibility === 'public' before offering them. See List booking pages.
  • Rate limits. A publishable key gets 120 requests a minute, shared by every visitor using it. Each guest costs about three to five: the page, a slot list per day they look at, the booking. A page with heavy traffic should load the booking page once on your server and cache it.

Keep the key publishable

This file ships the key to every visitor, which is what publishable keys are for: they can read your pages and slots and create bookings, nothing else. Never put a secret clndr_sk_ key in a page like this. Anything that lists, confirms or cancels bookings belongs on a server; see the TypeScript SDK.

Port the plain-JavaScript booking formPaste into Claude Code

Add a clndr.pro booking form to this site without React, following https://docs.clndr.pro/tutorials/vanilla-js.md (read it first; the API reference is https://docs.clndr.pro/api-reference).
 
- Use fetch against https://www.clndr.pro/api/v1 (always www) with a publishable key (clndr_pk_...) in an Authorization: Bearer header. Never use a secret key in browser code.
- Load GET /booking-pages/{slug}, GET /booking-pages/{slug}/slots?date=YYYY-MM-DD (the host's calendar day), then POST /bookings with bookingPageId, guestName, guestEmail, startTime and endTime copied from the slot, and responses as [{ questionId, answer }].
- Insert host-written text with textContent, never innerHTML. Show times with Intl.DateTimeFormat in the guest's timezone.
- On a 409 (the slot was taken or isn't open any more), reload that day's slots and ask the guest to pick again. Show "Request sent" instead of "booked" when the booking comes back pending.
- Match the site's existing markup and styles. Ask me for the booking page slug and which page this goes on, and leave a clearly marked placeholder for the publishable key.