Google Calendar Booking on Your Website, Without a SaaS
Add Google Calendar booking on your own website with no booking SaaS: free slots from free/busy, double-booking checks, reminders and Meet links.

Google Calendar booking on website pages you control needs no SaaS subscription. Your site reads the calendar's busy times through Google's free/busy API, turns the gaps into slots and creates an event when a visitor books. Danube Labs runs exactly this on danubelabs.net with no database. The calendar is the store. This article shows how it works and when Calendly or Google's own appointment schedules are the better choice.
How does Google Calendar booking on a website work?
Here is the visitor's side, step by step:
- The visitor opens
/book. No account is needed. - They pick a day and a slot, shown in their own timezone.
- They enter a name, an email and optional notes.
- The server checks again that the slot is free, creates the Google Calendar event with a Meet link and a Jitsi fallback room, and sends a confirmation email. Google sends its own calendar invitation too.
- Reminders arrive 24 hours before the call and when it starts.
The owner's side is quieter. A booking is an ordinary event on your calendar. The admin has an Appointments screen that lists the next 60 days grouped by day, with a Start call button that opens the Meet link. Cancelling from there deletes the event and notifies the attendee. Bookings made through Google's own appointment schedule show up in the same list, because they are ordinary events on the same calendar.
How do you get free slots when Google has no free-slots API?
Google's freebusy.query returns the busy intervals of the calendars you ask about, between a timeMin and a timeMax. It returns no free time. The site inverts the answer. It starts from your working hours, removes the busy intervals, and what is left are the bookable slots.
POST https://www.googleapis.com/calendar/v3/freeBusy
{
"timeMin": "2026-10-05T00:00:00Z",
"timeMax": "2026-10-26T00:00:00Z",
"items": [{ "id": "primary" }]
}Our settings are Monday to Friday, 10:00–18:00 Bucharest time, 30-minute calls, a 15-minute buffer, 12 hours of notice and 21 days ahead. A simplified version of the logic:
// Simplified. The real code lives in src/lib/booking/slots.ts
const busy = await freeBusy(timeMin, timeMax, calendarId);
const slots = [];
for (const day of nextDays(21)) {
for (const start of workingStarts(day, "Europe/Bucharest", 30)) {
if (start < now + 12 * HOUR) continue; // minimum notice
if (touchesBusy(busy, start, start + 30 * MIN, 15 * MIN)) continue; // buffer
slots.push(start);
}
}Working hours are wall-clock times in Europe/Bucharest, converted to UTC one day at a time. A 10:00 start is 08:00 UTC in winter and 07:00 UTC in summer. Tests cover both sides of the daylight-saving boundary, and we run them with npm run test:slots after touching the slot code.
How do you prevent double bookings and store them without a database?
Double-booking protection
Slots are recomputed on every page load. When the visitor submits the form, the server asks Google for free/busy again before it writes anything. If the slot was taken in the meantime, the site answers with HTTP 409 and the visitor sees a message that the slot was just taken. That 409 is our own response. Google's Calendar API uses 409 for duplicate identifiers.
A re-check is not a database lock. The check and the write are two separate calls, so the window for a clash is small but real. If your calendar takes many bookings a minute, use a tool built on transactions.
Booking state in private extended properties
Instead of a database, each booking keeps its state on the calendar event, in private extended properties:
| Key | Holds |
|---|---|
dlBooking | 1, marks the event as created by the site |
dlName, dlEmail | Who booked |
dlJitsi | The no-account video room |
dlLocale | The language of the emails |
dlTz | The visitor's timezone |
dlRemind24, dlRemindStart | Timestamps that stop a reminder going out twice |
Google's docs say private properties are set on one copy of the event, so the guest does not see them. Keys are limited to 44 characters, values to 1,024, and one event to 300 properties and 32 kB in total, which short keys like these fit with room to spare. The events.list call can also filter on a property, for example privateExtendedProperty=dlBooking=1.
How do confirmation emails, reminders and cancel links work?
Reminders from an external cron
The confirmation email goes out over SMTP when the booking is created, and Google sends its calendar invitation separately. The 24-hour reminder and the starting-now email come from one endpoint, GET /api/cron/reminders. A free job on cron-job.org calls it every 5 minutes with a bearer token:
curl -H "Authorization: Bearer $CRON_SECRET" https://danubelabs.net/api/cron/remindersHost schedulers depend on the host and the plan. Vercel's Hobby plan allows one cron run per day and fails the deployment if an expression would run more often. Netlify's scheduled functions run in UTC, have a 30-second limit, run only on published deploys and are a separate function type from a Next.js route. An outside scheduler that calls a normal route keeps the schedule independent of the host.
The endpoint is idempotent. Each send is recorded on the event itself, in dlRemind24 or dlRemindStart, so a double fire sends nothing twice. The starting-now window runs from 5 minutes before to 10 minutes after the start, which a 5-minute cron cannot miss. In our setup, cron-job.org disables a job after about 25 failed runs, so re-enable it if the endpoint returned errors for a couple of hours.
Signed cancel links
The confirmation email carries a link of the form /book/cancel?id=…&t=…. The t value is an HMAC of the event id, keyed with a secret that never leaves the server and compared with timingSafeEqual. Knowing an event id is not enough to cancel someone else's call. A minimal version of the idea in Node:
import { createHmac, timingSafeEqual } from "node:crypto";
function sign(id) {
return createHmac("sha256", process.env.BOOKING_SECRET).update(id).digest("hex");
}
function isValid(id, token) {
const a = Buffer.from(sign(id));
const b = Buffer.from(token);
return a.length === b.length && timingSafeEqual(a, b);
}The hash algorithm and the encoding are your choice. The length check matters because timingSafeEqual throws on buffers of different sizes.
Why Google Meet plus a Jitsi fallback?
Creating an event with a Meet link needs conferenceDataVersion=1 on the request and a unique requestId inside conferenceData.createRequest. Without the parameter, Google silently drops the conference. Reusing a request id across events leaks meeting access, so every event gets its own.
POST https://www.googleapis.com/calendar/v3/calendars/primary/events?conferenceDataVersion=1&sendUpdates=all
{
"summary": "Intro call",
"start": { "dateTime": "2026-10-12T07:00:00Z" },
"end": { "dateTime": "2026-10-12T07:30:00Z" },
"attendees": [{ "email": "visitor@example.com" }],
"conferenceData": {
"createRequest": {
"requestId": "unique-per-event",
"conferenceSolutionKey": { "type": "hangoutsMeet" }
}
},
"extendedProperties": { "private": { "dlBooking": "1", "dlTz": "Europe/London" } }
}Meet has one catch for outside visitors. A guest without a Google account has to knock and wait to be admitted unless the meeting access is set to Open, as Google's meeting access settings describe. So each booking also gets a Jitsi room with a random, unguessable name, such as https://meet.jit.si/danubelabs-…. Jitsi Meet is free and open source, and it needs no account and no lobby. Both links go into the confirmation email and the calendar event, and we watch both when a call is due.
How do timezones work for visitors and the owner?
A booking is stored as one absolute instant. Timezones only change how it is shown, so visitor and owner always see the same moment.
- The visitor sees slots in the device timezone, read with
Intl.DateTimeFormat().resolvedOptions().timeZone, with a dropdown to override it. - Emails use the zone the visitor booked from, stored as
dlTzon the event. - The owner sees Bucharest time in the admin, whatever the laptop clock says, plus the visitor's local time on each card.
- Google Calendar shows times in its own display zone, so set that to Bucharest in Google's settings.
We read the device timezone instead of guessing from the IP address, because the device clock stays correct behind a VPN and while travelling. The server groups slots by our calendar date, and the widget regroups them by the visitor's date. Without that step, a visitor in Auckland would see a 03:30 Tuesday slot listed under Monday.
What does the Google OAuth setup need?
The Gmail app password is no use here. It authenticates SMTP only, while the Calendar API speaks OAuth 2.0 and rejects it, whatever the Workspace plan. The OAuth setup happens once:
- Create a Google Cloud project and enable the Google Calendar API.
- Set the OAuth consent screen user type to Internal.
- Add the scope
https://www.googleapis.com/auth/calendar. - Create an OAuth client ID of type Web application, with a local redirect URI for the one-time consent.
- Run a small script once, approve access and copy the refresh token it prints.
The Internal setting matters. Google's OAuth documentation says a project with an External consent screen in Testing status gets refresh tokens that expire in 7 days. Internal is available when you own a Google Workspace domain, it skips Google's verification review, and in our setup it is not subject to that 7-day rule. Do not flip it to External.
Seven environment variables carry the configuration: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN, GOOGLE_CALENDAR_ID, GOOGLE_CALENDAR_TIMEZONE, BOOKING_SECRET and CRON_SECRET. On Netlify they reach the running functions only after the next build, so redeploy after setting them.
When are Calendly or Google appointment schedules the better choice?
A custom booking page costs developer time and leaves you with OAuth, a scheduler and email delivery to maintain. A ready-made tool is the better choice when:
- You need payments, round-robin team scheduling or CRM integrations, which Calendly sells on its paid plans.
- You offer many event types. Calendly's free plan has one event type and one calendar connection, and automations and reminders sit on paid plans. Check its current pricing page.
- You take many bookings a minute and need transactional guarantees.
- Nobody on your side can maintain the code.
Google appointment schedules need no code. They offer a booking page, Google Meet, confirmation emails and up to 5 reminders, and visitors do not need to be signed in to a Google account. A personal account can create one basic schedule, and Google's comparison page puts multiple schedules and payments on paid plans.
| Own build | Google appointment schedule | Calendly | |
|---|---|---|---|
| Cost | Developer time, free tools | Included, premium features on paid plans | Free plan with limits, paid plans per seat |
| Look and domain | Fully yours | Google-hosted page | Hosted page, customisable |
| Where bookings live | Your calendar | Your calendar | Calendly, plus a connected calendar |
| Maintenance | You | The vendor |
Build your own when you already have a website and a developer, want booking inside your own design and domain, need one or two event types and want the data to stay on your own calendar.
Try the booking flow on danubelabs.net
The free intro call on our booking page runs on exactly this system. Danube Labs adds booking to websites as part of web development, from Google's appointment schedules to a custom flow like this one. The site behind it works without a database, which we explain in the git-based CMS article, and you can see it in the danubelabs.net case study.
Frequently asked questions
- How do I add booking to my website with Google Calendar?
- Connect your site to the Google Calendar API with OAuth, read busy times with freebusy.query, and turn the gaps inside your working hours into slots. When a visitor books, create a calendar event with a Meet link. You need an OAuth client, a place to store a refresh token and an email sender. Google's appointment schedules do the same with no code.
- Does Google Calendar have a free-slots API?
- No. The freebusy.query method returns only busy intervals for the calendars you request, between a start and an end time. To offer slots, take your working hours, subtract the busy intervals, apply a buffer and a minimum notice period, and list what remains. That inversion is a few dozen lines of code, and daylight-saving changes need tests.
- How do you prevent double bookings without a database?
- Recompute free slots on every page load and check free/busy again at the moment of submit, before creating the event. If the slot is gone, return HTTP 409 and ask the visitor to pick another. This is a re-check, not a lock, so a very tight race is possible in theory. Busy calendars should use a tool built on transactions.
- Do visitors need a Google account to book?
- Not on the danubelabs.net booking page. Visitors pick a slot and type a name and email. Google appointment schedules also let people book without being signed in. For the call itself, a Meet guest without a Google account must knock to be admitted, so each booking also gets a Jitsi room that needs no account.
- Why do Google OAuth refresh tokens expire after 7 days?
- Google's documentation says a project whose consent screen is External with a publishing status of Testing receives refresh tokens that expire in 7 days. An Internal consent screen, which is available for Google Workspace domains, is not in that case in our setup, which is why Danube Labs uses Internal for its booking integration.
- When should I use Calendly instead of building my own booking?
- Use Calendly or a similar tool when you need payments, team round-robin scheduling, CRM integrations or many event types, or when nobody can maintain the code. Build your own when you want booking inside your site's design and domain, have a developer, need only one or two event types and want data on your own calendar.


