Spite is an underrated product requirement
I like Calendly. I genuinely do. It solved a real problem for a long time. But at some point my setup started feeling cramped enough that I hit my favorite engineering trigger: respectful annoyance.
I wanted separate booking flows for different contexts, interviews, networking chats, and occasional mentoring calls. The free plan effectively gave me one active event type and one straightforward public flow at a time. If your schedule is simple, that is fine. If your schedule has nuance, it starts to feel like trying to fit three meetings into one hoodie pocket.
So instead of paying immediately, I did what many engineers do when mildly inconvenienced: I opened my editor and made this someone else’s future problem (specifically, my own, but in TypeScript).
What changed in Calendly, and why it felt different
I wanted to sanity check whether I was imagining things. Here is what I found:
- Calendly’s current pricing page lists Free as 1 event type and 1 connected calendar.
- The Help Center plan breakdown (updated September 11, 2025) says the same thing: one event type on Free.
- Calendly’s legacy plans doc (updated March 20, 2026) explains that Essentials/Professional are no longer sold to new customers, but existing users keep legacy features.
- A community response from ~2023 says “one active event type on free” has historically been the baseline, which tracks with many users accidentally comparing trial/legacy behavior to free behavior later.
So was it a sudden product betrayal? Not exactly. Was it still frustrating in day to day use? Absolutely. Both can be true.
References:
- Calendly pricing
- Calendly subscription plans (Help Center)
- Calendly legacy plans
- Community thread on free plan event-type limits
The goal for my replacement
I did not need to rebuild all of Calendly. I needed one thing: a reliable interview booking flow for my personal site and chatbot, grounded in my actual calendar availability, with enough control to avoid chaos.
Requirements:
- Use my real Google Calendar availability (no fake static slots).
- Enforce interview-friendly windows only.
- Handle OAuth token failures without waking me up at 11pm.
- Keep the UX simple: email + pick time + done.
Architecture in plain English
I built a small scheduling layer in SvelteKit with a few API routes and one auth helper:
/api/calendar/availability: returns open slots./api/calendar/book: creates the event after a second availability check./api/calendar/oauth/startand/api/calendar/oauth/callback: reconnect flow when OAuth refresh token goes stale.google-calendar-auth.ts: manages OAuth config + refresh token persistence.
The front end is intentionally boring (compliment). Enter email, click a slot, send invite. Boring is good when your goal is “this should always work.”
Token persistence (where most DIY schedulers fall apart)
The hardest part was not slot math. It was auth durability.
I persist the Google refresh token in Postgres:
- Create table if missing:
google_oauth_tokens. - Read token from DB first, env second.
- If Google rotates a refresh token in a token response, store the new one.
- If
invalid_granthits, clear stale token and force reconnect flow.
This is the part that let me stop babysitting env vars every time auth got weird.
Slot generation logic
I intentionally constrained the booking window to keep interviews predictable:
- Timezone: America/Los_Angeles
- Weekdays only
- 8:00am–2:00pm PT window
- 30-minute slots
- 24-hour minimum notice
- Lookahead capped to next 5 working days
The availability endpoint generates candidate slots first, then calls Google FreeBusy and removes overlaps. So the user only sees times that survive both my constraints and real calendar conflicts.
Defensive booking flow
The booking endpoint validates again at request time:
- Checks email format.
- Re-checks notice and working-window rules.
- Runs FreeBusy again for race conditions.
- Inserts event with both attendees (candidate + me) and sends updates.
That second FreeBusy check matters. Without it, two fast clicks can produce one awkward apology email.
OAuth reconnect flow
When Google throws invalid_grant, the API returns a reconnect URL instead of a vague “something broke” message. From there:
/api/calendar/oauth/startsends user through consent.- Callback exchanges code for tokens.
- Refresh token gets persisted.
- Booking resumes without redeploy gymnastics.
I also split OAuth clients per app/domain because sharing one client across multiple projects can create token churn and confusion. That one change removed most of the “why did this break again?” moments.
What I like about this approach
- I own the constraints and UX.
- I can tune behavior per use case (interviews vs chats).
- No dependency on pricing tiers for basic routing logic.
- Failures are explicit and recoverable.
What I gave up
- I now own auth edge cases and operational drift.
- I lost some polished SaaS convenience out of the box.
- I have to keep this code healthy as APIs evolve.
Worth it for me, because control and reliability for this specific flow mattered more than feature breadth.
Final thought
This whole project started with “I am mildly annoyed.” It ended with a booking flow that matches how I actually work, survives token weirdness, and fits directly into my stack.
Sometimes the right move is to pay for the product. Sometimes the right move is to ship the thing yourself and learn a lot along the way. This time, I chose option two. Spite-driven development, but make it production-safe.