I didn't want to pay for Calendly. So we built our own booking page.
I've got meetings across my consulting, Stibnite, and client calendars. If one calendar says I'm free but another has a call on it, I'm still busy.
It checks all three before offering a time.
You pick a time, add your details, and get a Google Meet invite. The page looks like the rest of the site. You don't need to connect your calendar or figure out which of mine to use.
We built the original scheduler for Stibnite, then used the same backend for this site. Here's how it works, the services behind it, and some prompts you can adapt if you want to build something similar.
The services we used
We already had some of this running. That matters when you're deciding whether building your own makes sense.
| Service or tool | What we used it for |
|---|---|
| Google Calendar API | Check busy time across the connected calendars and create the booked event. |
| Google Meet | Add a video call to the calendar invite. |
| Google Cloud and Workspace | Set up the service account and permissions to act as the booking host. |
| Vercel | Host the sites and run the server functions that handle availability and bookings. |
| Supabase | Store booking records and temporary claims on a time slot. The Stibnite version also uses those records to distribute calls between hosts. |
| Mailgun | Send the host a heads-up when someone books, and send an alert when a booking fails. |
| Claude Code | Help build the original Stibnite scheduler. |
The Stibnite site uses Astro. This site's booking page is plain HTML, CSS, and JavaScript. Both use the same scheduling backend. We use Luxon for time zone calculations on the server and the browser's built-in Intl tools to display times.
Google sends the guest's calendar invitation. Mailgun handles the extra notifications. Those are separate jobs.
1. Write down the booking rules first
Before getting into the calendar connection, decide what someone should actually be able to book.
Here's our current setup:
- 30, 45, or 60-minute calls.
- Monday through Friday, 9 to 5 in the host's time zone.
- Start times on the hour or half hour.
- 15 minutes clear before and after a call.
- At least four hours' notice.
- Booking up to three weeks ahead.
A call also has to finish within working hours. A 4:30 start works for a 30-minute call. It doesn't work for a 60-minute call.
Put those rules in one configuration file. Otherwise, changing the booking window becomes a hunt through the page, the API, and whatever else has a copy of it.
The prompts below are reconstructed examples based on our implementation. They're starting points you can adapt, rather than a transcript of the original build.
Prompt: define the job
Help me plan a booking page for my existing website. It needs to check multiple Google calendars for one host, offer 30, 45, and 60-minute calls, and create a Google Meet invite. Use weekday hours of 9 to 5 in the host's time zone, a 15-minute buffer, four hours' notice, and a 21-day booking window.
Inspect the existing site first. Propose the architecture, settings, permissions, and failure handling before writing code. Keep the booking rules in one place. Flag anything you can't verify.
2. Set up Google access
Our backend uses a Google service account with domain-wide delegation. That lets it act as an approved host in Google Workspace. This setup needs a Workspace admin. Google's service account documentation covers the delegation steps.
- Create a Google Cloud project and enable the Google Calendar API.
- Create a service account for the scheduler.
- Have the Workspace admin authorize its client ID for the required calendar scopes. Our code uses
calendar.freebusyandcalendar.events.owned. - Share the other calendars with the host so the backend can check their busy time. Check the sharing policies if they're in different organizations.
- Verify access to every calendar before showing available times.
Delegation and calendar sharing are two separate pieces. Setting up the service account doesn't automatically give it access to every calendar you own somewhere else.
We store the credentials on the server in Vercel. The backend uses these environment variables:
GOOGLE_SERVICE_ACCOUNT_KEYSUPABASE_URLSUPABASE_SERVICE_ROLE_KEYMAILGUN_API_KEY
Our Google key is stored as base64-encoded JSON. That's just an encoding. It's still a secret. None of these credentials belong in the page's JavaScript.
3. Check all the calendars that can make you busy
This is the bit that mattered most for me.
We give the host a primary calendar, which receives new bookings. Then we list the other calendars whose events should block time.
The backend sends those calendar IDs to Google's FreeBusy endpoint. It returns busy intervals for each calendar. We combine those intervals before calculating what to offer.
For example, a client call from 10 to 11 blocks that time on my personal booking page too. With our buffer, an 11 o'clock intro call won't be offered. An 11:30 call can be, assuming the rest of the time is clear.
We don't copy all those events into a new calendar. We check them where they already live. The public page gets available slots, without the event titles, attendees, or descriptions.
And if one of the configured calendars can't be read, we don't offer that host's time. A permissions error doesn't mean I'm available.
Prompt: combine the calendars
Add a primary booking calendar and a list of additional busy calendars to the host configuration. Query all of them through Google Calendar FreeBusy and combine their busy intervals before generating slots. Create new bookings only on the primary calendar.
If any required calendar is missing or returns an error, withhold that host's availability. Return only available slots to the browser. Add tests for a conflict on each calendar and for a calendar that can't be read.
4. Calculate available times on the server
Our availability endpoint starts with the host's working hours. It generates possible start times for each meeting length, then removes anything that conflicts with busy time, buffers, notice, or the booking window.
Time zones need a little care here. My working day is based on Mountain Time. The visitor might be in London or Auckland. The server calculates the slots in the host's time zone and returns timestamps. The browser displays them in the visitor's selected zone.
That includes the date. A slot can fall on Tuesday for me and Wednesday for the person booking it.
The page detects the visitor's time zone, lets them change it, and remembers their choice on that device. We use named zones like America/Denver, so the calculations can account for daylight saving changes.
We cache availability briefly to avoid hitting Google every time someone opens the page. But the times shown on the page aren't a reservation. We check again when someone confirms.
5. Claim the slot, check again, then create the invite
Two people can have the same booking page open. Both can see the same time. Disabling the submit button won't handle that.
Here's the booking sequence:
- Validate the requested time and the guest's details on the server.
- Create a temporary claim for that host and time in Supabase.
- Check all the host's connected calendars again, live.
- If the time is still clear, create the Google Calendar event with the guest and a Meet link.
- Mark the booking confirmed and send the host a heads-up through Mailgun.
- Show the visitor the confirmation.
The database rejects overlapping pending claims for the same host. We use a PostgreSQL exclusion constraint for that. It can prevent overlapping time ranges, including claims that would leave too little buffer between calls. Our pending claims expire after two minutes.
Google Calendar holds the confirmed meeting. Supabase holds the booking record and coordinates requests while they're being processed.
When creating the event, we request Meet conference data and tell Google to send updates to the guests. The relevant options are conferenceDataVersion: 1 and sendUpdates: 'all'. Google's event creation reference explains both.
There's another case to handle: Google times out after receiving the request. The event might have been created anyway. Our code stops and flags that for follow-up. Automatically trying another host at that point could send the guest two invites.
If the slot's been taken, the page clears the selection and loads the latest openings. If the heads-up email fails after the event is created, the meeting still exists. We don't undo a booking because an extra notification failed.
Prompt: handle competing bookings
Implement booking as a server-side sequence: validate, claim the slot in Supabase, recheck Google Calendar, create the event and Meet link, record confirmation, then notify the host. Use a database constraint to reject overlapping pending claims for the same host, including the required gap between calls. Expire abandoned claims.
Return a conflict response when the slot is taken. Treat a timeout during event creation as an unknown outcome and flag it for follow-up. Don't automatically create another event. A notification failure after a successful booking mustn't turn it into a failed booking.
6. Reuse the backend across sites
Once the Stibnite scheduler existed, we didn't need another copy of the calendar integration for this site.
This site has its own booking page and two small Vercel functions: GET /api/availability and POST /api/book. They forward requests to the consulting endpoints on the shared Stibnite backend. The Google, Supabase, and Mailgun credentials stay there.
Both versions use the same internal host key for me. That means pending bookings compete for the same claim, even if one visitor is on Stibnite and another is on this site.
The Stibnite page also supports booking with either of us. It combines available slots across Matt and Tommy. If someone chooses “Anyone,” the backend gives priority to the available host with fewer confirmed bookings over the last 30 days, then the one booked least recently.
That's different from checking several calendars for one person. My personal page always books me. It still checks all three of my calendars.
7. Test the cases that can make it wrong
A successful test booking is a start. I'd also check these before sending people to the page:
- A busy event on each connected calendar blocks the slot.
- A calendar permission error hides availability.
- Two requests for the same host and time can't both hold overlapping claims.
- A meeting added after the page loads is caught by the live check.
- 30, 45, and 60-minute calls respect closing time and buffers.
- Times and dates display correctly across midnight and daylight saving changes.
- The guest gets an invite with a working Meet link.
- The page works on a phone and with a keyboard.
- Database, calendar, and email failures produce the right response.
We kept the scheduling calculations separate from the network calls. That lets us test the rules with fixed dates and mock calendar responses using Node's built-in test runner.
Prompt: review what can break
Review this booking implementation for calendar conflicts, concurrent requests, time zone errors, and partial failures. Inspect the code and existing tests. Focus on unreadable secondary calendars, two simultaneous bookings, changes since the page loaded, daylight saving transitions, and an event-creation timeout.
Use fixed dates and mocked external services for automated tests. Don't create real events or send email. List the checks that still need a manual test on a preview deployment.
Keep the scope small
We built an intro-call scheduler. We haven't added payments, a routing questionnaire, a CRM integration, or our own cancellation and rescheduling flow.
That kept the job manageable. We could use the calendar, database, hosting, and email services already in the stack, and build the connection between them.
There's still something to maintain. Permissions can change. Credentials need managing. Booking failures need someone to follow up. The cost includes that work, along with whatever the services charge for your usage.
That's the trade I made with this Calendly alternative. I wanted to avoid the subscription. Running the thing is still my job.
If you're building this yourself, start with one host and get the full booking working. Then add the other calendars and test a conflict on every one. Add multiple hosts after that, if you need them.
The part I'd spend the most time on is what happens after someone clicks confirm. That's where this has to work.
Get the full build prompt
I've put the whole build brief in one prompt -- the calendar setup, booking rules, failure handling, and tests. Swap in your details and give it to your coding assistant.
Leave your email and I'll send you a copy.
Need a hand? Email me.