Tutorial · Google Apps Script · Free Tools

How to Build a Free Appointment Booking System
with Google Sheets & Calendar

No Zapier. No Calendly. No paid APIs. Just HTML, a bit of JavaScript, and Google's free infrastructure — running 24/7 on your terms.

June 2026 · 12 min read · Intermediate

The Problem: Stop Paying for What You Can Build in an Afternoon

Let's be honest. If you run a barbershop, a consulting practice, a dental clinic, or any service-based business, you've probably looked at Calendly's pricing page and winced. $10–$16/month per user just to let someone pick a time slot. Acuity Scheduling? Same story. Square Appointments? Great — until you realize the "free" tier disappears the moment you need anything slightly advanced.

The dirty secret of the SaaS booking industry is that the core problem they solve — "customer picks a date and time, data lands somewhere you can see it" — is a fundamentally simple data pipeline. You don't need a $500M company to build that for you.

In this tutorial, we're going to build a fully functional appointment booking system from scratch. Here's what it does:

💡

Who is this for? Small business owners, freelancers, and agency developers who want a booking system they fully control, without handing over a monthly fee to a third party forever.

Let's build it.

The Tech Stack: Why Google Apps Script?

Before we write a single line of code, let's talk about why this stack makes sense.

When your HTML form submits data, it needs a server-side listener — something that receives the request, validates it, and writes to your storage layer. Normally you'd spin up a Node.js or Python server, pay for hosting, manage SSL certificates, handle uptime... the list goes on.

Google Apps Script (GAS) eliminates all of that. It's Google's server-side JavaScript runtime, tightly integrated with the entire Google Workspace ecosystem. You write a function called doPost() — the equivalent of an Express route handler — and Google hosts it for you, forever, for free, behind a stable HTTPS URL.

Here's the full stack at a glance:

The key architectural reason we use doPost instead of a paid API: Google Apps Script acts as a CORS-safe HTTP endpoint. By sending data as text/plain (a trick you'll see below), we avoid browser preflight requests entirely — no need to configure server headers, no middleman services like Zapier or Make.


01 The Frontend Booking Form

This is the HTML page your clients will see. It's a clean, modern form that collects the appointment details and sends them to your Apps Script backend. Copy this into your index.html or embed it inside your existing site.

📌

Find the line const GAS_URL = 'YOUR_APPS_SCRIPT_URL_HERE'; near the top of the script block. You'll replace that placeholder in Step 3 after deploying your backend.

booking-form.html HTML
<!-- Booking Form — paste this wherever you need it -->

<style>
  .booking-wrap {
    max-width: 480px;
    margin: 0 auto;
    font-family: system-ui, sans-serif;
    color: #1a1714;
  }
  .booking-wrap h2 {
    font-size: 24px;
    font-weight: 700;
    margin-bottom: 8px;
  }
  .booking-wrap p.sub {
    color: #666;
    font-size: 14px;
    margin-bottom: 28px;
  }
  .form-group {
    margin-bottom: 18px;
  }
  label {
    display: block;
    font-size: 13px;
    font-weight: 600;
    letter-spacing: .04em;
    text-transform: uppercase;
    margin-bottom: 6px;
    color: #444;
  }
  input, select {
    width: 100%;
    padding: 11px 14px;
    border: 1.5px solid #d0ccc5;
    border-radius: 4px;
    font-size: 15px;
    background: #fff;
    transition: border-color .2s;
    outline: none;
  }
  input:focus, select:focus {
    border-color: #c94f2c;
  }
  input.error, select.error {
    border-color: #dc2626;
    background: #fff5f5;
  }
  .row-2 {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
  }
  .submit-btn {
    width: 100%;
    padding: 14px;
    background: #1a1714;
    color: #fff;
    border: none;
    border-radius: 4px;
    font-size: 14px;
    font-weight: 600;
    letter-spacing: .1em;
    text-transform: uppercase;
    cursor: pointer;
    transition: background .2s;
    margin-top: 8px;
  }
  .submit-btn:hover { background: #c94f2c; }
  .submit-btn:disabled { opacity: .6; cursor: not-allowed; }
  .msg-error {
    display: none;
    background: #fef2f2;
    border: 1px solid #fca5a5;
    color: #991b1b;
    padding: 12px 16px;
    border-radius: 4px;
    font-size: 14px;
    margin-top: 14px;
  }
  .msg-success {
    display: none;
    background: #f0fdf4;
    border: 1px solid #86efac;
    color: #166534;
    padding: 20px 24px;
    border-radius: 4px;
    font-size: 16px;
    text-align: center;
    margin-top: 20px;
  }
</style>

<div class="booking-wrap">
  <h2>Book an Appointment</h2>
  <p class="sub">Fill in your details and we'll confirm shortly.</p>

  <div id="form-panel">
    <div class="form-group">
      <label for="f-name">Full Name</label>
      <input type="text" id="f-name" placeholder="Jane Smith">
    </div>
    <div class="form-group">
      <label for="f-phone">Phone Number</label>
      <input type="tel" id="f-phone" placeholder="+1 (555) 000-0000">
    </div>
    <div class="row-2">
      <div class="form-group">
        <label for="f-date">Date</label>
        <input type="date" id="f-date">
      </div>
      <div class="form-group">
        <label for="f-time">Time</label>
        <select id="f-time">
          <option value="">Select a time</option>
          <option>9:00 AM</option>
          <option>9:30 AM</option>
          <option>10:00 AM</option>
          <option>10:30 AM</option>
          <option>11:00 AM</option>
          <option>11:30 AM</option>
          <option>2:00 PM</option>
          <option>2:30 PM</option>
          <option>3:00 PM</option>
          <option>4:00 PM</option>
          <option>5:00 PM</option>
        </select>
      </div>
    </div>
    <div class="form-group">
      <label for="f-service">Service</label>
      <select id="f-service">
        <option value="Haircut">Haircut — 30 min</option>
        <option value="Haircut + Beard">Haircut + Beard — 45 min</option>
        <option value="Consultation">Free Consultation — 20 min</option>
      </select>
    </div>
    <button class="submit-btn" id="f-btn"
            onclick="submitBooking()">
      Request Appointment
    </button>
    <div class="msg-error" id="form-error"></div>
  </div>

  <div class="msg-success" id="form-success"><strong>You're booked!</strong>
    We'll send a confirmation to your phone shortly.
  </div>
</div>

<script>
// ⬇ Replace this with your deployed Apps Script URL (Step 3)
const GAS_URL = 'YOUR_APPS_SCRIPT_URL_HERE';

// Set min date to today
document.getElementById('f-date').min =
  new Date().toISOString().split('T')[0];

async function submitBooking() {
  const btn    = document.getElementById('f-btn');
  const errDiv = document.getElementById('form-error');
  const okDiv  = document.getElementById('form-success');

  const name    = document.getElementById('f-name').value.trim();
  const phone   = document.getElementById('f-phone').value.trim();
  const date    = document.getElementById('f-date').value;
  const time    = document.getElementById('f-time').value;
  const service = document.getElementById('f-service').value;

  // Basic validation
  errDiv.style.display = 'none';
  if (!name || !phone || !date || !time) {
    errDiv.textContent  = 'Please fill in all required fields.';
    errDiv.style.display = 'block';
    return;
  }

  btn.disabled     = true;
  btn.textContent  = 'Sending…';

  const payload = { name, phone, date, time, service };

  try {
    // Sending as text/plain avoids CORS preflight with GAS
    const res  = await fetch(GAS_URL, {
      method:   'POST',
      headers:  { 'Content-Type': 'text/plain;charset=utf-8' },
      body:     JSON.stringify(payload),
      redirect: 'follow'
    });
    const data = await res.json();

    if (data.status === 'success') {
      document.getElementById('form-panel').style.display = 'none';
      okDiv.style.display = 'block';
    } else {
      errDiv.textContent   = 'Server error: ' + (data.message || 'unknown');
      errDiv.style.display = 'block';
      btn.disabled = false;
      btn.textContent = 'Request Appointment';
    }
  } catch (err) {
    errDiv.textContent   = 'Network error. Is the script deployed? ' + err.message;
    errDiv.style.display = 'block';
    btn.disabled = false;
    btn.textContent = 'Request Appointment';
  }
}
</script>

The key trick in the JavaScript is sending the body as text/plain with a JSON-stringified payload. This bypasses the browser's preflight OPTIONS request that would otherwise get blocked by Google's CORS headers. The Apps Script backend will parse the raw string on its end.


02 The Backend — Google Apps Script

This is where the magic happens. The script below does three things: parses the incoming JSON, appends a row to your Google Sheet, and creates a Google Calendar event — all in a single serverless function call.

Before you paste the code, grab two IDs:

  1. Sheet ID: Open your Google Sheet. The URL looks like docs.google.com/spreadsheets/d/SHEET_ID/edit. Copy the bold part.
  2. Calendar ID: Open Google Calendar → Settings → select your calendar → scroll to "Integrate calendar" → copy the Calendar ID (looks like an email address).

Now go to script.google.com, create a new project, delete the placeholder code, and paste this:

Code.gs Google Apps Script
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CONFIG — Replace these two values before deploying
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
var SHEET_ID    = 'YOUR_GOOGLE_SHEET_ID_HERE';
var CALENDAR_ID = 'YOUR_GOOGLE_CALENDAR_ID_HERE';
var SHEET_NAME  = 'Appointments';  // must match your tab name
var DURATION_MIN = 45;              // default appointment length in minutes

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// doPost — triggered every time the form submits
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function doPost(e) {
  try {

    // 1. Parse the incoming JSON payload
    var payload = JSON.parse(e.postData.contents);
    var name    = payload.name    || '';
    var phone   = payload.phone   || '';
    var date    = payload.date    || '';  // format: YYYY-MM-DD
    var time    = payload.time    || '';  // format: "10:00 AM"
    var service = payload.service || 'Appointment';

    // 2. Validate required fields
    if (!name || !phone || !date || !time) {
      return respond('error', 'missing_fields');
    }

    // 3. Write to Google Sheets
    var ss    = SpreadsheetApp.openById(SHEET_ID);
    var sheet = ss.getSheetByName(SHEET_NAME);

    if (!sheet) {
      return respond('error', 'sheet_error: tab not found — check SHEET_NAME');
    }

    // Add headers automatically if the sheet is empty
    if (sheet.getLastRow() === 0) {
      sheet.appendRow([
        'Timestamp', 'Name', 'Phone',
        'Date', 'Time', 'Service', 'Calendar Event ID'
      ]);
    }

    // 4. Create the Google Calendar event
    var eventId = '';
    try {
      var startDateTime = parseDateTime(date, time);
      var endDateTime   = new Date(startDateTime.getTime() + DURATION_MIN * 60000);

      var cal   = CalendarApp.getCalendarById(CALENDAR_ID);
      var event = cal.createEvent(
        service + ' — ' + name,
        startDateTime,
        endDateTime,
        {
          description: 'Client: ' + name + '\nPhone: ' + phone +
                       '\nService: ' + service
        }
      );
      eventId = event.getId();
    } catch (calErr) {
      // Calendar creation failed — still save the row, just log the error
      eventId = 'CAL_ERROR: ' + calErr.message;
    }

    // 5. Append the row to Sheets
    sheet.appendRow([
      new Date(),   // Timestamp
      name,
      phone,
      date,
      time,
      service,
      eventId
    ]);

    return respond('success', 'Appointment saved and calendar event created.');

  } catch (err) {
    return respond('error', 'write_error: ' + err.message);
  }
}

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Helper: build a JSON response with CORS headers
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function respond(status, message) {
  var output = ContentService
    .createTextOutput(JSON.stringify({ status: status, message: message }))
    .setMimeType(ContentService.MimeType.JSON);
  return output;
}

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Helper: convert "YYYY-MM-DD" + "10:00 AM" to a Date object
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function parseDateTime(dateStr, timeStr) {
  // dateStr = "2025-07-14", timeStr = "10:00 AM"
  var parts   = dateStr.split('-');
  var year    = parseInt(parts[0]);
  var month   = parseInt(parts[1]) - 1; // JS months are 0-indexed
  var day     = parseInt(parts[2]);

  var timeParts = timeStr.match(/(\d+):(\d+)\s*(AM|PM)/i);
  var hours   = parseInt(timeParts[1]);
  var minutes = parseInt(timeParts[2]);
  var period  = timeParts[3].toUpperCase();

  if (period === 'PM' && hours !== 12) hours += 12;
  if (period === 'AM' && hours === 12) hours = 0;

  return new Date(year, month, day, hours, minutes, 0);
}
📋

Sheet setup: In Google Sheets, rename the default tab from "Sheet1" to "Appointments" (or change SHEET_NAME in the script to match whatever name you prefer). The script will auto-create column headers on the first submission.


03 Deploy & Go Live

This is the step most tutorials gloss over. Follow these exactly — one wrong setting here is responsible for 80% of the support questions you'll see in comments.

  1. In the Apps Script editor, click Deploy → New deployment (top-right button).
  2. Click the gear icon next to "Type" and select Web app.
  3. Set Execute as: Me (your Google account).
  4. Set Who has access: Anyone — this is critical. Without this, the form will get a 403 error.
  5. Click Deploy. Google will ask you to authorize the script — accept all permissions (Sheets + Calendar access).
  6. Copy the Web app URL that appears. It looks like: https://script.google.com/macros/s/AKfy.../exec
  7. Paste that URL into your HTML file, replacing 'YOUR_APPS_SCRIPT_URL_HERE'.
  8. Test the form end-to-end. Check your Sheet and Calendar for the new entry.
⚠️

Every time you modify Code.gs, you must create a new deployment (Deploy → New deployment) or the live URL will still run your old code. Editing the script without redeploying is the #1 cause of "my changes don't work" frustration.


Troubleshooting: Common Errors & How to Fix Them

This section covers every issue you're likely to hit. Bookmark it — these are real errors from real deployments.

The form says "success" but nothing appears in Google Sheets

This is the most common and most confusing error. There are three likely causes:

1. Stale deployment cache. After editing your Code.gs, you clicked "Deploy → Manage deployments" and hit "Update" on your existing deployment. Unfortunately, Google sometimes caches the old version for several minutes, or indefinitely for some users. The fix: always create a new deployment after any code change, then update your HTML to point to the new URL.

2. Incorrect Content-Type or body format. If your frontend is sending the body as application/json or application/x-www-form-urlencoded, Google's CORS layer may strip or transform the payload before it reaches doPost. The reliable workaround — which the code above already uses — is to send as text/plain;charset=utf-8 and parse e.postData.contents on the server side with JSON.parse().

3. The wrong sheet tab name. If your Google Sheet tab is named "Sheet1" (the default) but your script has SHEET_NAME = 'Appointments', the call to getSheetByName() returns null and the script silently fails. Make sure the tab name in Sheets exactly matches the variable — capitalization included.

TypeError: Failed to fetch (network error in the browser console)

This almost always means one of three things: the script hasn't been deployed as a Web App yet (only saved), the "Who has access" setting is "Only myself" instead of "Anyone", or you're opening the HTML file directly from your filesystem (file:///...) instead of through a server. Browsers block cross-origin requests from file:// origins. Either use a local dev server (npx serve .) or upload the HTML to any web host.

Google Calendar events are created but the time is wrong (off by hours)

The new Date(year, month, day, hours, minutes) constructor uses the timezone of the Apps Script project, not the user's browser timezone. Go to your Apps Script project settings (the gear icon on the left sidebar) and set the Script timezone to match your business location. Then redeploy.

Authorization error: "This app is blocked" or "Access denied"

When you first authorize the script, Google shows a scary "This app isn't verified" screen. Click "Advanced" → "Go to [project name] (unsafe)". This is a Google UX quirk for unverified scripts — yours is safe because you wrote it. The warning goes away if you publish the script to the Google Workspace Marketplace, but that's overkill for a private booking form.

The script runs but throws "Cannot read property 'contents' of undefined"

You're testing doPost by clicking "Run" inside the Apps Script editor. That triggers the function with no arguments, so e is undefined. You can't test a doPost handler by clicking Run — it must be triggered by an actual HTTP POST. Use curl or your frontend form against the deployed URL to test it properly.


Conclusion: You Just Saved $360 a Year

That's the math on a single Calendly Professional seat. Multiply that by the number of staff or clients on your team, and the savings compound fast.

More importantly, you now own your data. Every appointment lives in a spreadsheet you control, backs up automatically to your Google Drive, and can be exported, filtered, or piped into any other tool you want — no API fees, no vendor lock-in, no "we're sunsetting this feature" emails.

From here, the natural next steps are:

🙌

If you get this running, drop a comment below — I'd love to hear what type of business you built it for. And if you hit an error that isn't covered in the troubleshooting section, describe exactly what you see in the browser console and I'll do my best to help diagnose it.

Ready to build it?

Copy the code above, deploy in under 15 minutes, and never pay for a booking tool again.

Start with Step 1 →
HN
Written by
Hugo Nina

I'm a developer obsessed with one question: why pay a SaaS company forever for something you can build in an afternoon? I specialise in no-code automations and zero-cost systems — booking tools, CRMs, inventory trackers, notification pipelines — built entirely on free-tier infrastructure like Google Apps Script, Sheets, and Airtable. Every tutorial on this blog is something I've deployed in a real business context. If your team is haemorrhaging money on monthly subscriptions for tools that do one simple thing, let's talk.

Need a Custom Tool? →