Vibe code your own Calendly
Vibe code your own Calendly as weekly hours and a booking page. Calendar OAuth, DST, and the last-second double-book are the other product. Here is the split.
You can vibe code your own Calendly. Start with the booking page. Calendly looks like one. A 30-minute call, a week of hours, a link you send. The catalog calls it your availability rules plus one pleasant page.
You pay $12–$20 a person a month. That is $144–$240 a year, and the card says that price scales with the team. The verdict is “Very vibeable”.
Yesterday I wrote about a TinyPNG alternative in the browser. That was a regular backlog post. This one is a teardown: what is public about the architecture, which half you can vibe-code, and which half you should leave alone.
Vibe code your own Calendly as a booking page
The Calendly page splits the work into three rows.
The first row is 6 hours. One meeting type, weekly availability, bookings. That is a form, a grid of start times, and a row in a table. You can vibe-code this.
The second row is 2 days. Google Calendar sync, timezones, reschedule links. Still one consultant. The calendar is now in the loop.
The third row is 1 month and up. Team routing, payments, every calendar provider. Skip that row.
If you tell an agent to “clone Calendly”, it aims at the third row. The useful v1 is the first one.
Here is the shape of the data. Nothing else has to exist at 6 hours.
const meetingType = {
id: 'intro-30',
title: 'Intro call',
durationMin: 30,
timezone: 'America/New_York',
hours: { wdays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], from: '09:00', to: '17:00' },
bufferBeforeMin: 0,
bufferAfterMin: 0,
noticeMin: 240,
horizonDays: 14,
}
const booking = {
id: 'booking-184',
meetingTypeId: 'intro-30',
startAt: '2026-10-21T14:00:00.000Z',
guestEmail: '[email protected]',
guestTimezone: 'Europe/Rome',
status: 'confirmed',
}
A meeting type, a weekly window, a booking stored in UTC. Date overrides can wait until you take a Friday off. Reschedule waits until the first guest asks.
The audience line on the page is one consultant who offers 30- and 60-minute meetings. If that is you, a self-hosted page is enough. If a sales team already shares one Calendly org, keep paying. The calendar is their source of truth, the same way Linear is for issues. How Linear works is that split.
The same v1 also replaces Cal.com, SavvyCal, and Acuity Scheduling if what you wanted was a personal booking link. Team routing still belongs on the skip row.
Calendly availability is hours minus busy
Calendly publishes a REST API at https://api.calendly.com. I am summarizing their availability guide and the scheduling guide, not reconstructing their database.
You list event types with GET /event_types. Then you ask for bookable starts with GET /event_type_available_times. start_time and end_time are required, must be in the future, and cannot span more than 31 days. That endpoint is the cooked product. Hours, busy calendars, buffers, notice, and the horizon have already been applied.
The raw ingredients sit on two other endpoints. GET /user_availability_schedules returns weekly rules and date overrides. A rule is a weekday or a specific date. Intervals look like from: '09:00' and to: '17:00'. The schedule carries an IANA timezone, America/New_York in their share API example.
GET /user_busy_times returns busy ranges as UTC ISO-8601 start and end, from calendars marked “Check for Conflicts”. The window cannot be greater than 7 days. Cooked availability, 31 days. Raw busy, 7 days. Split a longer search. That is their sentence. I will not guess why the busy call is shorter.
A vibe-coded page needs none of those endpoints. Copy the product rule: weekly hours in a named timezone, bookings as UTC instants, busy as intervals you subtract.
Booking is POST https://api.calendly.com/invitees. You send the event type URI, a UTC start_time with a trailing Z, and the invitee name, email, and IANA timezone. Their docs are explicit: start_time must still be an open slot at booking time. A 404 means re-query and offer alternatives.
The response includes cancel_url and reschedule_url. There is no reschedule endpoint. That is in their developer FAQ.
Slot generation is interval math
The mini-prompt on the page is “The slot generator is just interval subtraction”. Availability minus busy minus buffers, sliced into starts, across DST.
Do the math in UTC. Expand the weekly rule into concrete intervals for the requested day. Subtract busy intervals padded by buffers. Clip to minimum notice and the booking horizon. Slice whatever survives into duration-sized starts on a 30-minute grid.
A 9:00 start in America/New_York moves in UTC when DST flips. Eastern Daylight Time is UTC−4. Eastern Standard Time is UTC−5. In 2026 the fallback is Sunday, 1 November.
zonedToUtc('2026-10-26', '09:00', 'America/New_York')
//'2026-10-26T13:00:00.000Z'
zonedToUtc('2026-11-02', '09:00', 'America/New_York')
//'2026-11-02T14:00:00.000Z'
Store the instant. Show 9:00 in the owner’s zone. Convert for the guest. Calendly’s scheduling guide says to use IANA names. Their international scheduling post says invitees see open times in their own zone. The 6-hour row can pin the page to the owner’s zone.
Spring-forward is worse. On 8 March 2026, 02:30 does not exist in New York. On 1 November, 01:30 exists twice. Treat “Calendly always picks the later instant” as an educated guess. I have not seen them publish that choice. What I would ship is: reject a local time that does not exist, and for a repeated local time use the earlier offset.
Calendly’s buffer help says a 30-minute meeting with 15-minute buffers before and after needs 60 minutes of free time. The meeting stays 30 minutes. They also allow a buffer to hang past working hours, so a 16:30 booking with a 30-minute after-buffer is valid if the window closes at 17:00. For v1 I would clip buffers inside the window so 16:30 does not appear.
Fine-tune availability covers minimum notice and how far ahead a guest can book. The catalog already has both on the meeting type. Enforce them when you generate starts, not only at submit.
function sliceStarts(free, durationMs, stepMs) {
const starts = []
for (const { start, end } of free) {
const from = Date.parse(start)
const to = Date.parse(end)
for (let t = from; t + durationMs <= to; t += stepMs) {
starts.push(new Date(t).toISOString())
}
}
return starts
}
sliceStarts(
[{ start: '2026-10-21T13:00:00.000Z', end: '2026-10-21T17:00:00.000Z' }],
30 * 60 * 1000,
30 * 60 * 1000,
)
//['2026-10-21T13:00:00.000Z', '2026-10-21T13:30:00.000Z', ... '2026-10-21T16:30:00.000Z']
That is a sketch of the last step, not Calendly’s code. Feed it intervals that already have busy time punched out. The DST work happened earlier, when you expanded 09:00–17:00 into those UTC bounds.
Two guests, one slot
Two people can open the same Tuesday. Both click 14:00. The slot list is already stale.
The guardrail on the page is the whole trick: a final availability check inside the booking transaction. The mini-prompt adds a short hold so the contact form is not a footrace, then a uniqueness constraint on meeting type plus start time.
Calendly’s API encodes the same idea. The slot must still be open at POST /invitees time. Their error cheat sheet maps a missing slot to 404, then “re-query availability and propose alternatives”.
async function confirmBooking(db, { meetingTypeId, startAt, guestEmail }) {
const stillOpen = await db.isOpen(meetingTypeId, startAt)
if (!stillOpen) {
return { ok: false, alternatives: await db.nearest(meetingTypeId, startAt) }
}
try {
const booking = await db.insertBooking({ meetingTypeId, startAt, guestEmail })
return { ok: true, booking }
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT') {
return { ok: false, alternatives: await db.nearest(meetingTypeId, startAt) }
}
throw err
}
}
That is a D1 insert, not Calendly’s protocol. The unique index is the last check. The loser gets the next two starts. I would not try to match whatever lock they use on a cluster.
A five-minute hold is the feature I would add once two real guests collide. Expired holds release at read time, without a cleanup job.
Google Calendar is the other product
The 2-day row starts when you connect a calendar. Calendly’s Google Calendar help (updated 4 September 2026) splits that job in two.
You pick calendars to check for conflicts. Busy events on those calendars make you unavailable. You pick one calendar to add events to. Bookings go there, and nowhere else.
Optional sync settings sit under that. Buffers can be written as separate events. Decline or delete a Calendly meeting in Google and Calendly cancels it. Change the time in Google and Calendly reschedules it. If the invitee edits their own copy, nothing happens. That last sentence is theirs.
I have not seen Calendly publish the sync protocol. Treat “they poll FreeBusy on every page view” as an educated guess that will melt a quota.
FreeBusy: query is POST https://www.googleapis.com/calendar/v3/freeBusy. You send timeMin, timeMax, and calendar ids. You get back busy ranges with an inclusive start and an exclusive end. That is the subtract list. The calendar.freebusy scope does not need event titles. Calendly’s getting-started post says invitees never see the details of your calendar, only the open times.
To stay current, Google’s push notifications watch a calendar over HTTPS. A channel expires. A 410 on a sync token means a full refetch. I will not claim Calendly uses events.watch. The hard part is still tokens, refresh, and a write that must not double-create an event.
Never expose calendar provider tokens to the browser. Encrypt them on the server and refresh there, so the booking page only sees starts.
One Google account is the 2-day row. Outlook, Exchange, and every other provider are the month. Calendly’s connect calendars post says the free plan is one calendar and paid plans go up to six. I would ship one Google connection and stop.
Reschedule is cancel plus create
Guest management without an account is a signed URL. Calendly puts cancel_url and reschedule_url on the invitee.
When an invitee reschedules, both webhook events fire. Webhook payloads for rescheduled events is the source. invitee.canceled has rescheduled: true. invitee.created has the new active invitee. old_invitee and new_invitee live on the invitee, not the event, because a group event can move one person.
Copy that shape even if you never emit webhooks. Cancel the old booking, insert the new one, in one transaction, after the same availability check. Issue fresh tokens and kill the old ones, or a forwarded email from last week can still move the new time.
The .ics file is the other 2-day detail. RFC 5545 wants UID, DTSTART, DTEND, and 75-octet folding. Keep the UID stable and bump SEQUENCE on a reschedule so Google Calendar updates the existing event. Emit UTC with a Z. Floating local times land on the wrong hour after DST.
Round robin is the skip
Team routing is why the third row exists. Calendly’s Round Robin overview describes two modes. Maximize for availability: any free host, then priority stars, then who has not had a recent meeting. Optimize for equal distribution: hide a host who is three or more meetings ahead until the others catch up.
That is a pool of calendars and a fairness counter. The non-goal on the page is the useful sentence: do not support teams, round-robin routing, payments, or multiple calendar providers in v1.
Doodle is a different job. Five proposed times, a vote grid, no availability engine. $7–$9 a person a month, 3 hours for the first row. If the rent is herding a group, build that.
What I would rebuild in a Calendly clone
I would rebuild Calendly for one consultant who currently pastes three times into email. One meeting type. Weekly hours. A public page. Name, email, a confirmation with an .ics. A uniqueness constraint on the start.
I would host it next to the product. I would add Google FreeBusy and a reschedule token after the first week of real bookings, which is the 2-day row.
I would not build round robin, payments, or Outlook. I would not rebuild Calendly for a company that already routes demos across a sales team.
Start with the first row
Go to Build your own Calendly. Copy the AHA prompt, or the stack you already write.
Tell the agent the first estimate only: one meeting type, weekly availability, bookings. Six hours.
If a team already shares the routing link, keep paying.