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:
- Presents a clean booking form on any website
- Sends form data to a Google Apps Script backend via a plain
fetch()call - Automatically writes each appointment as a new row in Google Sheets
- Simultaneously creates a Google Calendar event with the right time, duration, and client details
- Works 24/7, scales infinitely within Google's free tier, and costs you exactly $0.00/month
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:
- Frontend: Plain HTML/CSS/JS — no framework required
- Backend: Google Apps Script (
doPosthandler) - Database: Google Sheets (each row = one appointment)
- Calendar: Google Calendar (auto-created events)
- Hosting: Google's infrastructure (free tier is more than enough)
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 — 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:
- Sheet ID: Open your Google Sheet. The URL looks like
docs.google.com/spreadsheets/d/SHEET_ID/edit. Copy the bold part. - 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:
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 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.
- In the Apps Script editor, click Deploy → New deployment (top-right button).
- Click the gear icon next to "Type" and select Web app.
- Set Execute as: Me (your Google account).
- Set Who has access: Anyone — this is critical. Without this, the form will get a 403 error.
- Click Deploy. Google will ask you to authorize the script — accept all permissions (Sheets + Calendar access).
- Copy the Web app URL that appears. It looks like:
https://script.google.com/macros/s/AKfy.../exec - Paste that URL into your HTML file, replacing
'YOUR_APPS_SCRIPT_URL_HERE'. - 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.
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.
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.
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.
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.
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:
- Anti-overbooking: Add a
doGetendpoint that returns occupied time slots, and have the frontend grey them out dynamically. - Email confirmations: Use
GmailApp.sendEmail()inside the script to fire a confirmation email to the client on booking. - SMS reminders: Combine with a
time-based triggerin Apps Script that checks upcoming appointments and sends a reminder via a free SMS gateway.
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 →