appscript.dev
Automation Intermediate Calendar

Publish your free/busy availability

Share Northwind open slots without exposing meeting details — perfect for a booking page.

Published Sep 18, 2025

Booking pages are a fine idea — the problem is that the third-party ones want to read every meeting on your calendar to compute availability. For a studio like Northwind, where titles include client names and brief details, that is more data than you want to hand over. The other option is to publish your calendar publicly, which exposes every meeting subject to the world.

This script gives you a third path. Deployed as a web app, it returns a JSON list of open 30-minute slots in your working hours over the next week — slot times only, no titles, no attendees, no meeting bodies. Point a tiny booking page or a script at the URL and it has everything it needs without seeing any of the things it should not.

What you’ll need

  • Access to the calendar whose availability you want to publish. The script uses CalendarApp.getDefaultCalendar(); swap in getCalendarById for a shared resource.
  • A deployment as a web app (Apps Script editor → DeployNew deploymentWeb app). Choose “Execute as: me” and “Who has access: Anyone” if you want the booking page to call it unauthenticated.
  • Nothing else — no spreadsheet, no API keys.

The script

// How many days ahead to publish availability for.
const LOOKAHEAD_DAYS = 7;

// Working hours, 24-hour clock. 9am inclusive to 6pm exclusive.
const WORK_START_HOUR = 9;
const WORK_END_HOUR = 18;

// Length of each bookable slot, in minutes.
const SLOT_MINUTES = 30;

/**
 * Web app entry point. Returns the next week of open working-hour slots
 * as a JSON array, no event details exposed.
 */
function doGet() {
  const slots = freeSlots(new Date(), LOOKAHEAD_DAYS);
  return ContentService.createTextOutput(JSON.stringify(slots))
    .setMimeType(ContentService.MimeType.JSON);
}

/**
 * Computes 30-minute open slots in the user's working hours over the next
 * `days` weekdays. Returns an array of { start, end } ISO strings.
 *
 * @param {Date} start - The first day to check (today is fine).
 * @param {number} days - How many days forward to scan.
 * @return {Array<{start: string, end: string}>}
 */
function freeSlots(start, days) {
  const cal = CalendarApp.getDefaultCalendar();
  const slotMs = SLOT_MINUTES * 60 * 1000;
  const out = [];

  for (let d = 0; d < days; d++) {
    // 1. Walk forward one day at a time, snapped to midnight.
    const day = new Date(start);
    day.setDate(start.getDate() + d);
    day.setHours(0, 0, 0, 0);

    // 2. Skip weekends — Sunday (0) and Saturday (6).
    if (day.getDay() === 0 || day.getDay() === 6) continue;

    // 3. Step through the working hours in SLOT_MINUTES chunks.
    for (let h = WORK_START_HOUR; h < WORK_END_HOUR; h++) {
      const slotStart = new Date(day);
      slotStart.setHours(h);
      const slotEnd = new Date(slotStart.getTime() + slotMs);

      // 4. A slot is free only if the calendar returns no events in it.
      if (cal.getEvents(slotStart, slotEnd).length === 0) {
        out.push({
          start: slotStart.toISOString(),
          end: slotEnd.toISOString(),
        });
      }
    }
  }
  return out;
}

How it works

  1. doGet runs whenever the deployed web app URL is hit. It calls freeSlots and serialises the result as JSON.
  2. freeSlots walks the next seven days, one day at a time, snapping to midnight so the day arithmetic is consistent across daylight-saving transitions.
  3. It skips Saturday and Sunday (getDay() returns 6 and 0) so the booking page only offers weekday slots.
  4. For each weekday it iterates through working hours in SLOT_MINUTES chunks (30 minutes by default), from 9am inclusive to 6pm exclusive.
  5. For each candidate slot, it calls cal.getEvents(slotStart, slotEnd) and only pushes the slot onto the output array if no events overlap it. The slot is emitted as an ISO 8601 string pair — no titles, no attendees, no description.

Example run

A GET to the deployed web app URL returns something like this:

[
  { "start": "2025-09-19T09:00:00.000Z", "end": "2025-09-19T09:30:00.000Z" },
  { "start": "2025-09-19T09:30:00.000Z", "end": "2025-09-19T10:00:00.000Z" },
  { "start": "2025-09-19T14:00:00.000Z", "end": "2025-09-19T14:30:00.000Z" },
  { "start": "2025-09-22T11:00:00.000Z", "end": "2025-09-22T11:30:00.000Z" }
]

A booking page renders that as a grid of times the visitor can click. None of the busy times leak — they simply do not appear.

Run it

This is an HTTP endpoint, not a scheduled job:

  1. In the Apps Script editor, click Deploy → New deployment.
  2. Pick Web app, set “Execute as” to yourself and “Who has access” to Anyone (or Anyone with a Google account, if you want to restrict it).
  3. Approve the calendar authorisation prompt.
  4. Copy the deployment URL and call it from your booking page or test it directly in a browser.

To pick up changes after editing the script, redeploy as a new version.

Watch out for

  • ISO strings are in UTC. The booking page is responsible for converting to the visitor’s local time. If you want to publish a fixed display timezone, format with Utilities.formatDate and your preferred zone before serialising.
  • Every slot is one calendar getEvents call. With seven days, nine working hours and 30-minute slots that is 126 API calls per request, fast enough but not instant. If the page is hit often, cache the result in CacheService for a few minutes.
  • All-day events show up as overlapping every slot of that day, which is usually what you want for holidays. If you have an “On call” all-day marker that should not block bookings, filter getEvents results by title before counting.
  • “Anyone” access is genuinely public. Anyone who guesses the URL can fetch your free slots. Treat the URL like an unlisted link rather than a secret — it leaks availability shape, even if it does not leak meeting details.

Related