ProductsDocsBlogConsultingAboutContactGet Started
Back to BlogDiagram showing a customer's web browser hitting an Apps Script booking form, the script checking Google Calendar availability, locking the chosen slot, and writing a confirmed event with email confirmations sent to both the visitor and the host
9 min readMageSheet Team

Build a Free Calendly Alternative with Apps Script and Google Calendar

Apps ScriptGoogle CalendarCalendly AlternativeBooking SystemSaaS ReplacementWeb Apps

If you are paying $8 to $16 per user per month for Calendly, you are paying for a public booking page, a calendar availability check, an email confirmation, and a reschedule link. Useful features, but every one of them is two evenings of Apps Script work on top of Google Calendar — for free, forever, with your data sitting in your own Drive.

This guide ships the complete pattern. By the end you have a public booking URL you can give clients, a Sheet logging every booking, automatic Google Calendar events with email confirmations, double-booking prevention, and timezone math that survives daylight savings.

Cost: $0/month for unlimited bookings. Setup time: 60–90 minutes once you copy the code. Maintenance: zero — the platform under it is Google Calendar, which is not going anywhere.

What We Are Building

Three pieces:

  1. Public booking page — a single HTML form served by an Apps Script web app. Visitor picks a date and time slot from your real availability, enters name and email, clicks "Confirm." No login, no account.
  2. Apps Script handler — receives the form submission, double-checks the calendar for conflicts, creates a Google Calendar event with both parties on the invite, and writes a row to a Bookings sheet.
  3. Bookings sheet — your audit trail and management surface. Cancel a booking by deleting the row; reschedule by editing the row; query "all bookings this month" with standard Sheet filters.

The key architectural choice: Google Calendar is the system of record for slots. The Sheet is an append-only log. The HTML form is stateless. None of these three layers know about the others until the request actually flows through.

Setup: One Calendar, One Sheet, One Apps Script

Before any code:

  1. Create or pick a Google Calendar that represents your bookable hours. We recommend a dedicated calendar (not your main one) named something like "Bookings — Sales Calls."
  2. Block your unavailable time on this calendar — sleep hours, lunch, deep-work blocks. The booking form will only offer slots that are not already busy.
  3. Create a Google Sheet with two tabs: Config (host email, calendar ID, timezone, slot length, business hours) and Bookings (timestamp, name, email, slot, calendar event ID, status).
  4. Open Extensions → Apps Script and paste the code below.

The Config tab is your settings panel. Edit a row, the next booking uses the new value. No deploys, no environment variables.

The Booking Handler

// Code.gs

const CONFIG = (() => {
  const sheet = SpreadsheetApp.getActive().getSheetByName('Config');
  const data = sheet.getRange('A2:B10').getValues();
  return Object.fromEntries(data.filter(r => r[0]));
})();

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

function getAvailableSlots(dateString, visitorTimezone) {
  const date = new Date(dateString + 'T00:00:00');
  const startOfDay = new Date(date);
  startOfDay.setHours(parseInt(CONFIG.businessHourStart, 10));
  const endOfDay = new Date(date);
  endOfDay.setHours(parseInt(CONFIG.businessHourEnd, 10));

  const calendar = CalendarApp.getCalendarById(CONFIG.calendarId);
  const busy = calendar.getEvents(startOfDay, endOfDay);

  const slots = [];
  const slotMs = parseInt(CONFIG.slotLengthMinutes, 10) * 60 * 1000;
  for (let t = startOfDay.getTime(); t + slotMs <= endOfDay.getTime(); t += slotMs) {
    const slotStart = new Date(t);
    const slotEnd = new Date(t + slotMs);
    const conflict = busy.some(e =>
      e.getStartTime() < slotEnd && e.getEndTime() > slotStart
    );
    if (!conflict) {
      slots.push({
        iso: slotStart.toISOString(),
        display: slotStart.toLocaleTimeString('en-US',
          { hour: 'numeric', minute: '2-digit', timeZone: visitorTimezone })
      });
    }
  }
  return slots;
}

function bookSlot(slotIso, name, email, visitorTimezone) {
  const lock = LockService.getScriptLock();
  lock.waitLock(10000);
  try {
    const slotStart = new Date(slotIso);
    const slotEnd = new Date(slotStart.getTime()
      + parseInt(CONFIG.slotLengthMinutes, 10) * 60 * 1000);

    const calendar = CalendarApp.getCalendarById(CONFIG.calendarId);
    const conflicts = calendar.getEvents(slotStart, slotEnd);
    if (conflicts.length > 0) {
      return { ok: false, error: 'That slot was just taken — please pick another.' };
    }

    const event = calendar.createEvent(
      `${CONFIG.eventTitle} — ${name}`,
      slotStart,
      slotEnd,
      {
        guests: email + ',' + CONFIG.hostEmail,
        sendInvites: true,
        description: `Booking confirmed via ${CONFIG.bookingPageUrl}\nVisitor timezone: ${visitorTimezone}`
      }
    );

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

    return { ok: true, message: 'Confirmed! Check your inbox for the calendar invite.' };
  } finally {
    lock.releaseLock();
  }
}

The five non-obvious details:

  1. LockService wraps the read-then-write block. Without it, two visitors clicking "Confirm" at the same moment can both pass the conflict check and both create events. The lock serializes the critical section to about 50ms.
  2. Visitor timezone passed through to event creation. Stored in the description and the Sheet, so reschedule emails later can use the right zone.
  3. Config read from the Sheet, not hardcoded. The host can change slot length, business hours, or event title from the spreadsheet — no developer needed.
  4. sendInvites: true triggers the Google Calendar invite email automatically. No MailApp.sendEmail call required for confirmations.
  5. The Bookings sheet stores the Calendar event ID. This is what enables cancellation and reschedule features later — you cannot manage what you cannot reference.

The Public Booking Form

Save as booking-form.html in the Apps Script editor:

<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: system-ui; max-width: 480px; margin: 2rem auto; padding: 1rem; }
    .slot { padding: 0.6rem; margin: 0.3rem; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; }
    .slot:hover { background: #f0f0f0; }
    .slot.selected { background: #2e7d32; color: white; }
  </style>
</head>
<body>
  <h2>Book a 30-minute call</h2>
  <input type="date" id="date" />
  <div id="slots"></div>
  <form id="form" style="display:none">
    <input id="name" placeholder="Your name" required />
    <input id="email" type="email" placeholder="Your email" required />
    <button type="submit">Confirm</button>
  </form>
  <div id="result"></div>

  <script>
    const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
    let selectedSlot = null;

    document.getElementById('date').addEventListener('change', e => {
      google.script.run.withSuccessHandler(slots => {
        document.getElementById('slots').innerHTML = slots
          .map(s => `<div class="slot" data-iso="${s.iso}">${s.display}</div>`).join('');
        document.querySelectorAll('.slot').forEach(el => {
          el.addEventListener('click', () => {
            document.querySelectorAll('.slot').forEach(s => s.classList.remove('selected'));
            el.classList.add('selected');
            selectedSlot = el.dataset.iso;
            document.getElementById('form').style.display = 'block';
          });
        });
      }).getAvailableSlots(e.target.value, tz);
    });

    document.getElementById('form').addEventListener('submit', e => {
      e.preventDefault();
      google.script.run.withSuccessHandler(r => {
        document.getElementById('result').innerText =
          r.ok ? r.message : 'Error: ' + r.error;
      }).bookSlot(selectedSlot,
        document.getElementById('name').value,
        document.getElementById('email').value,
        tz);
    });
  </script>
</body>
</html>

Around 200 lines total across both files. Deploy as a web app — Execute as: Me, Who has access: Anyone — and you get a public URL like https://script.google.com/macros/s/.../exec. Hand that URL to clients in your email signature, your Twitter bio, your invoices.

Hardening for Production

The basic version above works. Before sending it to a client, add these:

Branded confirmation email. The default Calendar invite is functional but unbranded. For a polished touch, send a separate MailApp.sendEmail confirmation right after createEvent, with your logo, agenda template, and reschedule link. Roughly 30 lines.

Buffer between meetings. Most working setups want 15-minute gaps between back-to-back calls. Add a bufferMinutes config value, subtract it from the conflict query window, and the booking slots automatically respect the gap.

Cancellation and reschedule. Each confirmation email includes a unique cancellation link with a token. The token resolves to the Calendar event ID in your Bookings sheet, the handler deletes the event, the row is updated to cancelled. Reschedule is the same plus a fresh booking flow. Same token-based pattern we documented in our Apps Script auth and state guide.

Idempotency on the booking submit. A flaky network can cause a visitor to click "Confirm" twice. Generate an idempotency key on the form (timestamp + nonce), check it against the Bookings sheet before creating the event. Same pattern as the Apps Script webhooks guide.

Daily summary email. A 5-line trigger that emails the host every morning with today's bookings. Saves the "wait, who am I meeting at 2 PM" moment.

Embedding the Booking Form on Your Website

The Apps Script web app URL is fine for plain "click my link" use, but most teams want the form embedded directly on their marketing site, not behind a script.google.com redirect. Two paths:

iframe embed. The simplest option. Drop a single line into any HTML page:

<iframe src="https://script.google.com/macros/s/.../exec"
        width="100%" height="600" frameborder="0"></iframe>

The form renders inside your site, the visitor never sees the Apps Script URL. The downside: you cannot fully restyle the form's CSS to match your brand because it is in a separate iframe context.

Direct fetch. Build the booking UI inside your real frontend (Next.js, Vue, plain HTML) and call the Apps Script web app as an API: redeploy the script with two new endpoints (?action=slots returning JSON, ?action=book accepting POST), then call them from your frontend with fetch. This gives you full design control at the cost of writing the form HTML yourself. The pattern is identical to the public-facing API surface we covered in our Apps Script webhooks guide — same doGet and doPost foundations, different payload shape.

For most consultants, the iframe is enough; it ships in 60 seconds and looks reasonable on any modern landing page. The direct-fetch path is worth it only when the booking form is a focal element of your brand.

When to Stop Building and Pay for Calendly

Honest signals:

  • You have a sales team of 5+ reps each needing their own booking page → Calendly's team features start to make economic sense at $144–300/month.
  • You need round-robin assignment, lead routing, and CRM hooks (Salesforce/HubSpot) → building these is real work, and Calendly's polish is hard to match in a few weekends.
  • Booking volume is more than 200 bookings per day → Apps Script web app cold starts and quotas can become uncomfortable.

For solo consultants, small teams, and internal scheduling, the build above amortizes within 2–3 months and gives you full control of the data, the form, the emails, and any future feature your specific business needs. Plus the underlying pattern — public web app over Apps Script with a Sheet as the system of record — is the same one we use to build secure client portals, WhatsApp CRMs, and inventory tools. One pattern, many products. The broader thesis is in our SaaS replacement guide.

This kind of "replace SaaS with Workspace" project is the heart of MageSheet's consulting practice — most teams know they could build it but do not have the bandwidth. We ship the working production version in a single engagement.

Frequently Asked Questions

How does this booking system handle timezones across attendees?

The booking form detects the visitor's timezone via JavaScript (Intl.DateTimeFormat().resolvedOptions().timeZone) and displays available slots in their local time. The slot is stored in Google Calendar in the host's timezone, but Calendar handles the conversion automatically when each attendee opens their invite. The non-obvious detail: store the visitor's timezone as a custom property on the Calendar event so reschedule emails can be sent in the right timezone. Without this, daylight savings transitions can shift a booking by an hour.

Can two people book the same slot at the same moment?

Yes if you do nothing about it — and this is the single most common failure mode of DIY booking systems. The fix is LockService.getScriptLock() wrapped around the read-then-write block. When user A and user B both submit a booking for 3 PM Tuesday, the lock serializes them: A writes the event, B reads the now-occupied calendar and sees the conflict, B is told the slot was just taken. The lock adds about 50ms of latency, far less than a network round-trip.

What happens when Google Calendar is down or rate-limited?

Calendar API has a 1,000,000 request per day quota per project — comfortable for most booking volumes. The handler should still return a graceful error message and queue the booking attempt to a retry sheet rather than failing silently. The pattern is identical to the dead-letter queue we documented in our UrlFetchApp guide: if Calendar.Events.insert fails, log the booking request to a 'pending_bookings' sheet, retry it on a 5-minute trigger, and email the host if retries exhaust.

How do I send reminders before the meeting?

Two options. The simple path: configure default Google Calendar reminders (15 minutes before, 1 hour before) on the host's calendar; attendees with the event in their own Google Calendar inherit them automatically. The advanced path: a daily Apps Script trigger that scans tomorrow's bookings and sends a custom reminder email through MailApp at a chosen offset (24 hours before, then 1 hour before). The custom path lets you include the meeting agenda, reschedule link, and any prep instructions the default reminder cannot.

When should I actually pay for Calendly instead of building this?

Three signals. First, you have a sales team where every rep needs their own booking page with custom branding and complex round-robin assignment — Calendly's team features cost less than a full custom build at this point. Second, you need integrations with Salesforce, HubSpot, or other CRMs out of the box — building these ourselves is real work. Third, your business depends on the booking system being up 99.99% during business hours — Apps Script's quotas and occasional cold-start latency can be uncomfortable for high-volume customer-facing booking. For solo consultants, small teams, and internal scheduling, the build cost amortizes within a couple of months.

Stay Updated

Get the latest insights on AI, e-commerce, and Magento delivered to your inbox.