Build a self-service booking web app
Let people reserve Northwind slots themselves — pick a time, get a confirmation.
Published Jul 31, 2025
Booking a session with Northwind used to mean an email thread: someone asks for a time, a coordinator checks the calendar, suggests a slot, waits for a reply, and finally creates the event. Four messages for one half-hour booking, and the calendar can still drift out of sync if two people ask at once.
This automation replaces the thread with a page. The script publishes a small web app that reads Northwind’s calendar, works out which 30-minute slots are genuinely free over the next week, and shows them in a dropdown. A visitor picks a time, enters their email, and the script books the event directly — the calendar is the single source of truth, so a slot that has just been taken never appears for the next person.
What you’ll need
- A Google Calendar to book against. This script uses the account’s default calendar — the one tied to the account that owns the Apps Script project.
- A second file in the Apps Script project named
Bookof type HTML (use File > New > HTML file and name itBook). - An Apps Script web app deployment with Execute as: Me and Who has access: Anyone (or Anyone, even anonymous if visitors should not need a Google login).
- No API keys and no spreadsheets — the calendar holds everything.
The HTML (Book.html)
<!-- The booking form. The <? ... ?> tags are Apps Script scriptlets,
evaluated on the server before the page is sent to the browser. -->
<form id="book">
<!-- One <option> per free slot. "slots" is passed in by doGet. -->
<select id="slot">
<? for (const s of slots) { ?>
<option value="<?= s.iso ?>"><?= s.label ?></option>
<? } ?>
</select>
<!-- The visitor's email — required so the form cannot submit empty. -->
<input id="email" type="email" placeholder="[email protected]" required>
<button type="submit">Book</button>
</form>
<script>
// Intercept the submit so the page does not reload, then hand the
// chosen slot and email to the server-side book() function.
document.getElementById('book').addEventListener('submit', (e) => {
e.preventDefault();
google.script.run
.withSuccessHandler(() => {
// Confirm to the visitor once the event has been created.
document.body.innerHTML = '<p>Booked. Check your email for the invite.</p>';
})
.withFailureHandler((err) => {
alert('Could not book that slot: ' + err.message);
})
.book({
slot: document.getElementById('slot').value,
email: document.getElementById('email').value,
});
});
</script>
The script
// How many days ahead to offer slots.
const DAYS_AHEAD = 7;
// The bookable window each day, as 24-hour start hours. 10..16 means
// the last slot starts at 16:00.
const FIRST_HOUR = 10;
const LAST_HOUR = 17;
// Slot length in minutes.
const SLOT_MINUTES = 30;
/**
* Web app entry point. Renders the booking form, pre-filled with the
* slots that are currently free.
*/
function doGet() {
const template = HtmlService.createTemplateFromFile('Book');
template.slots = openSlots();
return template.evaluate().setTitle('Book a Northwind slot');
}
/**
* Walks the next DAYS_AHEAD days and returns every 30-minute slot in
* the bookable window that has no existing calendar event.
*
* @return {Array<{iso: string, label: string}>} Free slots.
*/
function openSlots() {
const cal = CalendarApp.getDefaultCalendar();
const slotMs = SLOT_MINUTES * 60 * 1000;
const out = [];
// 1. Loop over each day in the booking window.
for (let d = 0; d < DAYS_AHEAD; d++) {
const day = new Date();
day.setDate(day.getDate() + d);
day.setHours(0, 0, 0, 0);
// 2. Loop over each candidate start hour within the day.
for (let h = FIRST_HOUR; h < LAST_HOUR; h++) {
const start = new Date(day);
start.setHours(h);
const end = new Date(start.getTime() + slotMs);
// 3. Skip slots in the past (today's earlier hours).
if (start < new Date()) continue;
// 4. A slot is free only if the calendar has no event in it.
if (cal.getEvents(start, end).length === 0) {
out.push({ iso: start.toISOString(), label: start.toLocaleString() });
}
}
}
return out;
}
/**
* Creates a calendar event for a chosen slot and invites the visitor.
* Called from the browser via google.script.run.
*
* @param {{slot: string, email: string}} params ISO slot + guest email.
*/
function book({ slot, email }) {
const start = new Date(slot);
const end = new Date(start.getTime() + SLOT_MINUTES * 60 * 1000);
// 1. Re-check the slot is still free — someone may have taken it
// between page load and submit.
const cal = CalendarApp.getDefaultCalendar();
if (cal.getEvents(start, end).length > 0) {
throw new Error('That slot was just taken — please pick another.');
}
// 2. Create the event and invite the visitor as a guest.
cal.createEvent('Northwind booking', start, end, { guests: email });
}
How it works
- When someone visits the web app URL,
doGetruns. It callsopenSlotsto work out availability and renders theBooktemplate with that list. openSlotsloops over the next seven days. For each day it steps through the bookable hours (10:00 to 16:00), building a 30-minute slot at each one.- For every slot it asks the calendar whether any event overlaps that window. Past slots and busy slots are dropped; only genuinely free times survive.
- The template renders one dropdown option per free slot — the visible label is human-readable, the option value is an ISO timestamp the server can parse back into a date.
- When the visitor submits, the browser calls the server-side
bookfunction with the chosen ISO slot and their email. bookre-checks the slot is still free, then creates the calendar event and adds the visitor as a guest, which sends them an invite.
Example run
A visitor opens the web app on a Monday. The calendar already has a meeting at 11:00 on Tuesday, so the dropdown shows everything except that slot:
Pick a time:
Mon 28 Jul, 14:00
Tue 29 Jul, 10:00
Tue 29 Jul, 11:30 <- 11:00 is missing, it is booked
Tue 29 Jul, 12:00
...
They choose Tue 29 Jul, 10:00, type their email, and click Book. The script creates a half-hour event titled “Northwind booking” at that time, invites them as a guest, and the page swaps to a confirmation. The next visitor who loads the app no longer sees the 10:00 slot.
Run it
This is an always-on web app, not a scheduled job.
- Save the project with both files — the script and the
BookHTML file. - Choose Deploy > New deployment > Web app, set Execute as: Me and Who has access to Anyone (or Anyone, even anonymous).
- Approve the authorisation prompt for Calendar access.
- Share the
/execURL — anyone with it can book.
When you change the script, create a New deployment (or edit the existing one) so visitors see the updated version.
Watch out for
- There is a small race window. Two people can both see the same free slot
and submit. The re-check inside
bookcatches the second one and throws, but the first writer wins — expect the occasional “slot was just taken”. getDefaultCalendaris the calendar of the account that deployed the app. To book a shared team calendar instead, swap inCalendarApp.getCalendarById('...').- Every slot is offered regardless of weekday, so weekends appear too —
filter on
day.getDay()if you only want weekdays. - All times are computed in the script’s time zone (set under Project Settings). Visitors elsewhere see slots in that zone, not their own — state the zone on the page to avoid confusion.
- Anyone with the URL can book, and there is no spam protection. For a public link, add a confirmation step or rate-limit by email.
Related
Build a branded approval interface
Approve Northwind requests through a custom UI — clients click, decision is logged.
Updated Nov 8, 2025
Build an interactive quiz or assessment app
Run Northwind tests with scoring and feedback — questions in a Sheet, results in another.
Updated Nov 4, 2025
Build a multi-page web app with routing
Structure a real Northwind app across views — query-param routing, shared layout.
Updated Oct 31, 2025
Build a form-to-PDF web service
Convert Northwind form submissions to PDFs on the fly — POST in, PDF out.
Updated Oct 27, 2025
Build an expiring secure-download generator
Issue time-limited Northwind links via a web app — token in URL, server-side check.
Updated Oct 23, 2025