DEV Community

Cover image for Build a Calendly Alternative in Apps Script: Slot Math, Locking, and Abuse Guards
Hayrullah Kar
Hayrullah Kar

Posted on • Originally published at magesheet.com

Build a Calendly Alternative in Apps Script: Slot Math, Locking, and Abuse Guards

The first booking page I built double-booked me in week two. Two people picked the same 10:00 slot forty seconds apart, both saw "Confirmed," and I found out when they both dialled in. The calendar check had run for the second person before the first person's event was written.

That bug is the whole reason this post exists. A booking page looks like a form and a calendar read, and Calendly charges $8–16 per user per month for it. You can replace it with about 150 lines of Apps Script — but only if you handle three things the naive version gets wrong: the slot arithmetic at the boundaries, the read-then-write race, and the fact that a public web app will happily let a stranger call your booking function directly.

Here's the version that's been running without an incident since.

The shape of it

Three pieces, one Google account, no server:

  • doGet serves a stateless HTML form. No login, no session.
  • getAvailableSlots / bookSlot run as the owner and talk to Calendar.
  • A Bookings sheet is the append-only log — the audit trail you cancel and reschedule from.

Google Calendar is the source of truth. The Sheet never decides whether a slot is free; it only records what happened.

Setup: one calendar, one sheet, nine settings

Make a separate Google Calendar for bookable hours — not your personal one, or every dentist appointment becomes a booking conflict you have to explain. Then a Sheet with two tabs, Config and Bookings.

The Config tab is two columns, key in A and value in B, starting at row 2. This is the part most write-ups skip, and it's the part that breaks people:

Key Example value What it does
calendarId abc...@group.calendar.google.com Calendar → Settings → "Calendar ID"
timezone Europe/Istanbul Must match File → Project Settings → Time zone
businessHourStart 9 First bookable hour, host's local time
businessHourEnd 17 Last hour a slot may end at
slotLengthMinutes 30 Slot size
minNoticeHours 4 Don't offer a slot 10 minutes from now
eventTitle Discovery call Prefix for the calendar event
hostEmail you@yourdomain.com Added as a guest so it lands in your inbox
bookingPageUrl https://script.google.com/.../exec Written into the event description

Reading config from a Sheet means you change your hours without redeploying. But don't do what I first did and read it in a top-level IIFE — that runs on every execution, including each doGet, so every page load pays a Sheets read, and one typo in Config takes down the form itself. Cache it:

// Code.gs
function getConfig() {
  const cache = CacheService.getScriptCache();
  const hit = cache.get('cfg');
  if (hit) return JSON.parse(hit);

  const sheet = SpreadsheetApp.getActive().getSheetByName('Config');
  const rows = sheet.getRange('A2:B').getValues().filter(r => r[0]);
  const cfg = Object.fromEntries(rows);
  cache.put('cfg', JSON.stringify(cfg), 300); // 5 min
  return cfg;
}

function doGet() {
  return HtmlService.createHtmlOutputFromFile('booking-form')
    .setTitle('Book a time')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
Enter fullscreen mode Exit fullscreen mode

A2:B is open-ended on purpose. A hardcoded A2:B10 silently ignores the eleventh setting you add six months from now, and you will spend an afternoon wondering why it has no effect.

The slot math, as a pure function

Availability is the one piece worth extracting from Google's services entirely, because it's the piece with off-by-one bugs and it's the piece you can actually test:

// Returns the start timestamps of every slot that fits and is unclaimed.
// dayStart/dayEnd/busy are epoch ms — no Calendar objects in here.
function freeSlots(dayStart, dayEnd, slotMs, busy) {
  const out = [];
  for (let t = dayStart; t + slotMs <= dayEnd; t += slotMs) {
    const end = t + slotMs;
    const taken = busy.some(b => b.start < end && b.end > t);
    if (!taken) out.push(t);
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

The two strict inequalities in taken are the whole game. b.end > t means an event that ends at exactly 10:00 does not block the 10:00 slot; b.start < end means one that starts at exactly 10:00 does not block the 09:30 slot. Flip either to >= and every back-to-back meeting eats a neighbouring slot — the "why does my calendar look full when it isn't" bug.

I ran this through 21 assertions in plain Node before it went near a calendar: an empty 09:00–17:00 day at 30 minutes gives exactly 16 slots, a 12:00–13:00 event removes exactly two, a five-minute event still burns its whole slot, a 50-minute window offers one slot rather than two, and overlapping busy ranges collapse instead of double-removing. Both boundary cases above have their own test. (The harness I use for this is in unit-testing Apps Script.)

Now the Calendar side. Two filters here matter more than they look:

function busyRanges(calendar, from, to) {
  return calendar.getEvents(from, to)
    .filter(e => !e.isAllDayEvent())
    .filter(e => e.getMyStatus() !== CalendarApp.GuestStatus.NO)
    .map(e => ({
      start: e.getStartTime().getTime(),
      end: e.getEndTime().getTime()
    }));
}

// Build a Date at `hour` on `dateString` in the SCRIPT's timezone.
function hostTime(dateString, hour) {
  const parts = dateString.split('-').map(Number);
  return new Date(parts[0], parts[1] - 1, parts[2], hour, 0, 0, 0);
}

function getAvailableSlots(dateString, visitorTz) {
  const cfg = getConfig();
  const slotMs = Number(cfg.slotLengthMinutes) * 60000;
  const dayStart = hostTime(dateString, Number(cfg.businessHourStart));
  const dayEnd = hostTime(dateString, Number(cfg.businessHourEnd));

  const notice = Number(cfg.minNoticeHours) * 3600000;
  const earliest = Date.now() + notice;
  if (dayEnd.getTime() <= earliest) return [];

  const calendar = CalendarApp.getCalendarById(cfg.calendarId);
  const busy = busyRanges(calendar, dayStart, dayEnd);

  return freeSlots(dayStart.getTime(), dayEnd.getTime(), slotMs, busy)
    .filter(t => t >= earliest)
    .map(t => ({
      iso: new Date(t).toISOString(),
      display: Utilities.formatDate(new Date(t), visitorTz, 'HH:mm')
    }));
}
Enter fullscreen mode Exit fullscreen mode

Skipping the all-day filter is what produces the support ticket "your booking page says you're never available" — one all-day "Conference" entry spans your entire working day and wipes out every slot. And without the GuestStatus.NO filter, invitations you declined still block your calendar.

The iso field is UTC; only display is rendered in the visitor's zone. Send a local-looking string to the browser and you will eventually book someone at 3 a.m.

Booking, with the race closed

LockService is the fix for the double-booking that started this post. It serializes the read-then-write, so the second visitor's availability check runs after the first visitor's event exists and correctly comes back occupied:

function bookSlot(form) {
  if (form.company) return { ok: false, error: 'Rejected.' }; // honeypot

  const name = cleanText(form.name, 80);
  const email = String(form.email || '').trim().slice(0, 120);
  if (!name) return { ok: false, error: 'Please enter your name.' };
  if (!isEmail(email)) {
    return { ok: false, error: 'Please enter a valid email address.' };
  }
  if (overBookingLimit(email)) {
    return { ok: false, error: 'Too many bookings from this address today.' };
  }

  const cfg = getConfig();
  const slotStart = new Date(form.slotIso);
  const slotMs = Number(cfg.slotLengthMinutes) * 60000;
  const slotEnd = new Date(slotStart.getTime() + slotMs);

  // Re-derive the grid server-side: never trust the posted timestamp.
  const dateKey = Utilities.formatDate(slotStart, cfg.timezone, 'yyyy-MM-dd');
  const open = hostTime(dateKey, Number(cfg.businessHourStart)).getTime();
  const close = hostTime(dateKey, Number(cfg.businessHourEnd)).getTime();
  const onGrid = (slotStart.getTime() - open) % slotMs === 0;
  if (slotStart.getTime() < open || slotEnd.getTime() > close || !onGrid) {
    return { ok: false, error: 'That time is outside booking hours.' };
  }

  const lock = LockService.getScriptLock();
  if (!lock.tryLock(10000)) {
    return { ok: false, error: 'Server busy — please try again.' };
  }
  try {
    const calendar = CalendarApp.getCalendarById(cfg.calendarId);
    if (busyRanges(calendar, slotStart, slotEnd).length > 0) {
      return { ok: false, error: 'That slot was just taken — pick another.' };
    }

    const event = calendar.createEvent(
      cfg.eventTitle + '' + name,
      slotStart,
      slotEnd,
      {
        guests: email + ',' + cfg.hostEmail,
        sendInvites: true,
        description: 'Booked via ' + cfg.bookingPageUrl
      }
    );
    event.setTag('visitorTimezone', String(form.visitorTz || ''));

    SpreadsheetApp.getActive().getSheetByName('Bookings').appendRow([
      new Date(), name, email, slotStart,
      event.getId(), form.visitorTz, 'confirmed'
    ]);

    return { ok: true, message: 'Confirmed. The invite is in your inbox.' };
  } finally {
    lock.releaseLock();
  }
}
Enter fullscreen mode Exit fullscreen mode

Four details that aren't decoration:

  • tryLock over waitLock. waitLock throws when it times out, which surfaces to the visitor as a raw stack trace. tryLock returns false and you control the message.
  • releaseLock in finally. If createEvent throws, the lock still releases — otherwise your booking page is dead for the next ten seconds of every request.
  • The grid check. The lock stops two people booking the same slot; it does nothing about someone posting 2026-08-03T02:17:33Z. Re-deriving open/close/onGrid server-side is what makes the slot list authoritative rather than advisory.
  • event.getId() in the sheet. Without it, cancelling means searching the calendar by name and hoping.

Storing the visitor timezone as a calendar tag (not just a sheet column) is what makes rescheduling survive a daylight-savings change — the reschedule code reads it off the event it's actually moving.

The guards themselves are small, and all three are pure functions:

// Strip the characters Sheets would evaluate as a formula, then cap length.
function cleanText(value, maxLen) {
  return String(value == null ? '' : value)
    .replace(/^[=+\-@\t\r]+/, '')
    .trim()
    .slice(0, maxLen);
}

function isEmail(value) {
  return /^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/.test(String(value).trim());
}

function overBookingLimit(email) {
  const cache = CacheService.getScriptCache();
  const key = 'bk:' + Utilities.base64EncodeWebSafe(email.toLowerCase());
  const n = Number(cache.get(key) || 0);
  if (n >= 3) return true;
  cache.put(key, String(n + 1), 3600); // 3 per email per hour
  return false;
}
Enter fullscreen mode Exit fullscreen mode

cleanText is not paranoia. A name typed as =HYPERLINK("http://x","click") lands in a cell your accountant later opens, and it lands in the calendar event title too. It keeps Jean-Luc and Ayşe Yılmaz intact — it only strips the leading characters Sheets treats as formula starters.

The form

Save as booking-form.html in the same Apps Script project. The markup first:

<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: system-ui; max-width: 480px;
           margin: 2rem auto; padding: 1rem; }
    .slot { display: inline-block; padding: .6rem; margin: .3rem;
            border: 1px solid #ccc; border-radius: 4px; cursor: pointer; }
    .slot.selected { background: #3B7CDE; color: #fff; }
    .hp { position: absolute; left: -9999px; }
  </style>
</head>
<body>
  <h2>Book a 30-minute call</h2>
  <input type="date" id="date" />
  <div id="slots"></div>

  <form id="form" hidden>
    <input id="name" placeholder="Your name" required />
    <input id="email" type="email" placeholder="Your email" required />
    <input id="company" class="hp" tabindex="-1" autocomplete="off" />
    <button type="submit">Confirm</button>
  </form>
  <p id="result"></p>

  <!-- the wiring below goes in a script tag right here -->
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

And the wiring that goes in that script tag:

  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
  let slotIso = null;

  document.getElementById('date').addEventListener('change', function (e) {
    google.script.run.withSuccessHandler(function (slots) {
      const box = document.getElementById('slots');
      box.innerHTML = slots.length
        ? slots.map(function (s) {
            return '<span class="slot" data-iso="' + s.iso + '">' +
                   s.display + '</span>';
          }).join('')
        : 'No times left on that day.';
      box.querySelectorAll('.slot').forEach(function (el) {
        el.addEventListener('click', function () {
          box.querySelectorAll('.slot')
             .forEach(function (s) { s.classList.remove('selected'); });
          el.classList.add('selected');
          slotIso = el.dataset.iso;
          document.getElementById('form').hidden = false;
        });
      });
    }).getAvailableSlots(e.target.value, tz);
  });

  document.getElementById('form').addEventListener('submit', function (e) {
    e.preventDefault();
    const btn = e.target.querySelector('button');
    btn.disabled = true;                       // stop the double-submit
    google.script.run.withSuccessHandler(function (r) {
      document.getElementById('result').textContent =
        r.ok ? r.message : r.error;
      if (!r.ok) btn.disabled = false;
    }).bookSlot({
      slotIso: slotIso,
      name: document.getElementById('name').value,
      email: document.getElementById('email').value,
      company: document.getElementById('company').value,
      visitorTz: tz
    });
  });
Enter fullscreen mode Exit fullscreen mode

The company field is the honeypot — off-screen, tabindex="-1", no autocomplete. Humans never fill it; naive form bots fill every input they find. It costs one line in bookSlot and removes most of the automated noise.

Deploy → New deployment → Web app → Execute as: Me, Who has access: Anyone. You'll get the unverified-app consent screen on first authorization — that's expected for a script that isn't OAuth-verified; click through Advanced. The /exec URL is what you share.

What actually breaks in production

  • "Anyone" means anyone. google.script.run is not the only way to reach bookSlot. Anyone with the /exec URL can call it directly with fabricated input — which is why the honeypot, the email check, the rate limit, and the grid check all live server-side. A public web app with validation only in the browser is an open calendar.
  • Timezone drift between the script and the config. hostTime builds dates in the script's timezone (File → Project Settings), while Utilities.formatDate uses cfg.timezone. If those two disagree, slots quietly shift by the offset. Set both, and set them once.
  • Consumer Gmail invite limits. The Calendar API quota is generous, but a free @gmail.com account caps the invitations a script can send per day far lower than a Workspace account does. It's Workspace that makes this comfortable at volume.
  • The 5-minute config cache. Change your hours and the form can serve stale settings for up to five minutes. Fine in practice; confusing when you're testing. Drop the TTL to 30 seconds while you set things up.
  • No cancellation link yet. The event ID is in the sheet, which is the hard part, but visitors still have to email you. Token-based cancel/reschedule links are the first thing to build next.
  • Cold starts. The first request after an idle period takes noticeably longer than a warm one. At a few bookings a day nobody notices. At a few hundred, they will.

When to just pay Calendly

I run this for myself and for one-person consultancies, and it's clearly the right call there. It stops being the right call when you have five-plus reps each needing their own branded page with round-robin routing, when you need Salesforce or HubSpot integration out of the box, or when booking volume is high enough that Apps Script quotas and cold starts become a customer-facing problem. Building any of those yourself costs more than the subscription.

Wrap-up

The calendar read is the easy 20 lines. What separates a booking page that works from one that quietly double-books you is three specific things: strict inequalities in the slot comparison, a script lock wrapped around the read-then-write, and server-side validation that assumes the caller skipped your form entirely.

The production version — token-based cancel and reschedule links, buffer times between meetings, branded confirmation emails, and a retry queue for failed writes — is written up on the MageSheet blog.

Built by the MageSheet team.

Top comments (0)