Build SimplyBook.me
KINDAback to the verdict
A single-owner booking tool whose hard problems are computing availability from busy intervals without leaking what they are, holding a slot atomically so two clicks cannot both win, and keeping a calendar mirror correct through sync token expiry and channel renewal.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the application and the sync worker.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - accountfree at personal volume
Why The whole product is a correct mirror of one calendar, and the credentials decide which scopes you can ask for.
Get it Create a project with the provider, enable the calendar API, and create OAuth credentials with the narrowest scopes that allow reading free and busy information and creating events. open ↗
Verify
A free and busy query returns intervals and an event you create appears in the calendar - have readya few pounds a month
Why The OAuth redirect and the push notification receiver both need a reachable address with a certificate the provider trusts; self-signed will not do.
Get it A small host with a domain and a certificate, or a tunnel that terminates TLS properly for development.
Verify
The provider delivers a test notification to the address - API keyfree tier is ample
Why Confirmations and reminders go out through it, and a booking must survive its outage.
Get it Create an account, verify a sending domain and issue a sending-only key.
Verify
A confirmation arrives and the provider reports it accepted - decidefree
Why Each is asserted by a test, and deciding them after watching a failure means picking whatever the code already does.
Get it Write down buffer before and after, minimum notice, how far ahead people may book, how long a hold lives, and what happens to a slot in the missing hour.
Verify
All five are in the configuration and named in tests
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- calendars(id, provider, account_email, token_ref, sync_token, sync_token_at, channel_id, channel_expires_at, state, paused_reason) - busy_blocks(calendar_id, starts_at, ends_at, source_event_id) · times only, never a title, never an attendee - services(id, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, horizon_days) - hours(id, weekday, start_local, end_local, timezone) · the owner's working hours as local rules, not as instants - holds(id, service_id, starts_at, ends_at, expires_at, booking_id) · a short-lived claim with an exclusion constraint on overlap - bookings(id, service_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, remote_event_id, state, cancel_token_hash, created_at) - outbox(id, booking_id, kind, send_after, attempts, delivered_at, last_error) · confirmations, reminders and cancellations - The hold table is the lock. Nothing about the calendar is trusted to prevent a collision, because the calendar is a remote system you learn about late.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/booking.db | Services, hours, holds, bookings and busy intervals. No titles. |
CALENDAR_CLIENT_ID | required | 1234-redacted.apps.googleusercontent.com | OAuth client for the calendar grant. |
CALENDAR_CLIENT_SECRETsecret | required | redacted | Never in the repository. Revoke it to test the pause. |
PUBLIC_BASE_URL | required | https://book.example.com | OAuth redirect and the push notification receiver. HTTPS with a trusted certificate. |
HOLD_EXPIRY_SECONDS | required | 600 | How long a started booking blocks a slot before the timer releases it. |
SYNC_STALE_MINUTES | required | 15 | A mirror older than this refuses to offer slots. |
CHANNEL_RENEW_MARGIN_MINUTES | required | 120 | Renew ahead of the expiry the provider reports, since nothing renews itself. |
EMAIL_API_KEYsecret | required | re_...redacted | Confirmations and reminders, delivered from the outbox. |
The build, in order
Availability without reading anyone's diary
Open slots are derived from busy intervals and working hours, correctly across a clock change.
What you never fetch cannot leak.
Files
src/lib/calendar/freebusy.tstests/no-titles.test.tsNot from a fixed offset. The two transition days are the test.
Files
src/lib/hours.tstests/dst.test.tsA slot you cannot actually take is worse than no slot.
Files
src/lib/availability.ts
done when · tick each as it passeswatch out- Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works.
Two people, one slot, one winner
Concurrent bookings of the same slot produce one booking and one clear refusal.
The database says no, not your code.
Files
src/lib/holds.tstests/concurrent-book.test.tsOrder matters more than speed here.
Files
src/lib/booking.tsA hold nobody completed must not block the slot forever.
Files
worker/expire-holds.ts
done when · tick each as it passeswatch out- Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one.
Keeping the mirror honest
An event added in the calendar app appears in your busy blocks before somebody books over it.
Discard, full resync, carry on. Not an exception to log.
Files
src/lib/calendar/sync.tstests/sync-410.test.tsThere is no automatic renewal, so the timer is yours.
Files
worker/renew-channel.tsNo slots is honest; wrong slots is a double booking.
Files
src/lib/availability.ts
done when · tick each as it passeswatch out- Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks.
- Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded.
Times people can read, sent once
Confirmations and reminders state the time in the invitee's own zone and never arrive twice.
A time without a zone is a guess with a number on it.
Files
src/lib/timezones.tstests/rendered-times.test.tsThe clock change is the test.
Files
src/lib/reminders.tsA confirmed booking must survive a dead mail provider.
Files
worker/outbox.ts
done when · tick each as it passeswatch out- Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC.
Cancelling, leaving and taking your dataproduct builder
An invitee can cancel without an account, a revoked calendar grant is handled, and nothing is trapped.
A guessable cancellation link is a denial of service with manners.
Files
src/lib/tokens.tstests/token-scope.test.tsOffering slots you cannot honour is worse than offering none.
Files
worker/sync.tstests/revoked.test.tsThe calendar event holds personal data too.
Files
src/lib/export.tssrc/lib/erase.ts
done when · tick each as it passeswatch out- A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint.
That is the whole plan for SimplyBook.me. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Multiple staff and round robin routing.
- Payments, refunds and no-show policies.
- A second calendar provider.
- A site builder and a service marketplace.
- multi-calendar conflict handling
- race-free booking under load
- team routing and round robin
- reliable reminders and timezone edge cases
- Group bookings with a capacity per slot, which changes the hold constraint rather than the design
- A second calendar read-only for conflicts, once token invalidation has been survived in production
- Recurring availability exceptions such as holidays, expressed as the same local rules
Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.