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:
-
doGetserves a stateless HTML form. No login, no session. -
getAvailableSlots/bookSlotrun as the owner and talk to Calendar. -
A
Bookingssheet 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');
}
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;
}
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')
}));
}
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();
}
}
Four details that aren't decoration:
-
tryLockoverwaitLock.waitLockthrows when it times out, which surfaces to the visitor as a raw stack trace.tryLockreturnsfalseand you control the message. -
releaseLockinfinally. IfcreateEventthrows, 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-derivingopen/close/onGridserver-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;
}
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>
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
});
});
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.runis not the only way to reachbookSlot. Anyone with the/execURL 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.
hostTimebuilds dates in the script's timezone (File → Project Settings), whileUtilities.formatDateusescfg.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.comaccount 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)