A Calendly-style booking page. Visitors pick 30, 45 or 60 minutes, see only the times you're actually free, and get a Google Meet link, a calendar invite and a confirmation email from your Gmail — plus a link to move or cancel the meeting themselves.
There's no database. Google Calendar is the state: free/busy decides what's offered, and creating the event is the booking. Availability is re-checked server-side at the moment of booking, so two people racing for the same slot can't both win.
Everything is in src/config.ts — working hours, timezone, the three
meeting lengths, buffer between meetings, minimum notice, how far ahead the calendar
opens, a daily cap, and specific days off. Edit, commit, push; Vercel redeploys.
Working hours are expressed in your own timezone (America/New_York), so daylight
saving takes care of itself.
You need a Google Cloud OAuth client and a refresh token. This is a one-time job.
-
Go to https://console.cloud.google.com/ and create a project (call it anything).
-
APIs & Services → Library: enable the Google Calendar API and the Gmail API.
-
APIs & Services → OAuth consent screen: choose External, fill in the app name and your email, and add these scopes:
.../auth/calendar.readonly.../auth/calendar.events.../auth/gmail.send
-
Fill in Branding, then publish the app. Google won't let an External app leave Testing until Branding has an app home page, a privacy policy link and a terms of service link, all on a domain listed under Authorised domains — and that domain has to be verified in Search Console. This app serves all three pages itself, so once it's deployed at your own domain, point Branding at:
Field Value Application home page https://<your-domain>/Privacy policy https://<your-domain>/privacyTerms of service https://<your-domain>/termsAuthorised domain your registered domain (covers subdomains) Then set the publishing status to In production. This matters: while the app sits in Testing, Google expires refresh tokens after 7 days and bookings start failing a week after you set it up. Since you're the only person who ever signs in, the "unverified app" warning you'll click past once is harmless.
Deploy first (step 4 below), then come back and finish this — the URLs have to resolve.
-
APIs & Services → Credentials → Create credentials → OAuth client ID, type Web application. Under Authorized redirect URIs add exactly:
http://localhost:53682/callbackCopy the client ID and secret.
cp .env.example .env.localPut the client ID and secret in .env.local — or, easier, download the client's JSON
from the console (the ⬇ button on the credential) and let the script read it:
npm run import-clientIt picks up the newest client_secret*.json in ~/Downloads on its own, fills in both
values without printing the secret, and says straight away if the client is the wrong type
or missing the redirect URI. Pass a path if the file is somewhere else. Then:
npm run tokenThat opens Google's consent screen, you sign in as the account that owns the calendar,
and it writes GOOGLE_REFRESH_TOKEN straight into .env.local. It deliberately doesn't
print the token, so it can't end up in terminal scrollback, a screen recording or an
agent transcript — pass --print if you actually need it on screen.
The token grants calendar and send-mail access to your account — treat it like a password.
Copy it into Vercel from .env.local yourself.
npm run devPush to GitHub, import the repo on Vercel, and add these under
Settings → Environment Variables: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
GOOGLE_REFRESH_TOKEN, OWNER_NAME, OWNER_EMAIL, and SITE_URL.
SITE_URL needs to be the real address (e.g. https://book.example.com), because it's
baked into the reschedule links inside emails and invites — left unset, a booking made
through a Vercel preview URL would hand out a link that dies with that deployment.
No redirect URI is needed in production; the app only ever refreshes a token it already has.
POST /api/bookre-reads your free/busy and rejects the slot if it's gone.- Creates the event on your calendar with the guest as an attendee and a Google Meet link, and lets Google send the invite.
- Sends your own confirmation email from your Gmail, with the Meet link and the time in the guest's timezone.
If step 3 fails the booking still stands — the guest is told to use the invite instead.
Every event carries a bookedVia=booking-app marker, which is how the daily cap counts
what came in through the page without touching the rest of your calendar.
Every booking gets a /manage/<token> link, which goes into the confirmation email and
into the calendar invite's description — so it's findable from either. The guest can move
the meeting to any other free slot or cancel it outright, and you get the calendar update
either way.
The token is the event id plus an HMAC of it, so a link works with no login and can't be edited into someone else's booking. There's still nothing stored anywhere: the page reads the event back off your calendar to build itself.
Rescheduling patches the event rather than replacing it, so the Meet link and the invite thread survive the move, and the meeting being moved is excluded from its own availability check — otherwise its buffer would block the times either side of it. Meetings that have already started can't be changed from the link.
npm testCovers the scheduling rules on their own — working hours, buffers, notice, the horizon, the daily cap, and a daylight-saving switch — with no network involved.