Slot
Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.
Repository composition by tracked source files.
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git
README
Slot
A small calendar-first booking app built as one Cloudflare Worker deployment.
The visitor flow is intentionally simple:
date → time → duration → details → confirmed booking
When the selected meeting length is configured as paid, Stripe Checkout is inserted before confirmation:
date → time → duration → details → Stripe payment → confirmed booking
Meeting lengths marked free skip Stripe and use the normal confirmation flow.
There are no public accounts and no Google login for visitors. Google OAuth is private to the calendar owner.
Architecture
- React + Vite frontend
- Cloudflare Worker API
- Workers Static Assets, deployed with the Worker as one unit
- Cloudflare D1 for settings, booking records, OAuth state, and short-lived slot locks
- Google Calendar FreeBusy for live availability
- Google Calendar Events API for invites
- Google Meet generated on the event when enabled
- Optional Stripe Checkout payments through the Stripe API. Without Stripe, bookings confirm normally; when payment is enabled, selected meeting durations can still be configured as free
No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service, or separately hosted backend.
What v0.2 does
- Calendar-first booking page
- Reads live busy periods from one or more Google calendars
- Configurable weekly hours
- Configurable 15/30/45/60/90/120 minute durations
- Minimum notice, booking window, before/after buffers
- Google Meet, phone, in-person, or decide-later meeting modes
- Rechecks Google immediately before booking
- Small invisible per-IP booking-attempt limit plus a form honeypot for basic abuse resistance
- Uses transactional D1 slot locks to reduce double-booking races
- Creates the Google Calendar event and emails the attendee through Google Calendar
- Private
/adminUI authenticated through the configured Google account - Stores the Google refresh token encrypted at rest in D1 using an AES-GCM key kept as a Worker secret
- When a selected meeting length requires payment, creates only a private calendar hold until Stripe confirms payment; free meeting lengths confirm normally
- Verifies Stripe webhook signatures before recording payment or refund state
- Admin rescheduling moves the existing Google Calendar event and sends attendee updates
- Admin can suggest a new time without moving the original booking; the attendee gets a secure accept link through the Google Calendar update
- Cancelling a paid booking issues a full Stripe refund to the original payment method before Slot finalizes the cancellation
- Every confirmed booking gets a public
SLT-...booking ID shown on the confirmation screen and Google Calendar invitation; visitors can use it to manage, reschedule, or cancel a future booking from the public booking page - Admin Activity dashboard with booking, completion, cancellation, reschedule, payment, revenue, refund, and average-duration metrics plus a chronological lifecycle feed
- Admin Attendees dashboard groups booking history by attendee email, showing repeat bookings, completions, cancellations, reschedules, booked minutes, and payment/refund counts when payments are enabled
- Admin booking search matches public
SLT-...booking IDs, attendee names, and email addresses across upcoming and past bookings
First setup
Requirements:
- Node.js
- a Cloudflare account
- a Google Cloud project with Google Calendar API enabled
- a Google OAuth Web application client
Google Cloud setup
Google has two separate setup areas that both matter:
- Enable the Google Calendar API for the project.
- Create an OAuth client ID and client secret for this app.
Doing only one of these is not enough. If OAuth exists but the Calendar API is not enabled, Slot can sign in but Google Calendar calls will fail with a Calendar API / FreeBusy error.
1. Enable Google Calendar API
- Open Google Cloud Console.
- Select or create the project you want to use for Slot.
- Go to APIs & Services -> Library.
- Search for Google Calendar API.
- Open Google Calendar API and click Enable.
This is separate from OAuth credentials. The OAuth client controls sign-in; the enabled Calendar API controls whether the app may call FreeBusy and create events.
2. Configure OAuth consent
- In the same Google Cloud project, go to APIs & Services -> OAuth consent screen.
- Choose the audience that fits your use:
- Internal if this is only for your Google Workspace organization.
- External if you use a normal Gmail account or need access outside one Workspace organization.
- Fill in the app name, support email, and developer contact email.
- If the app is in External / Testing, add the same Google account you will use as Slot admin under test users.
Slot requests Calendar access only for the configured admin account. Visitors never log in with Google.
3. Create OAuth client ID and secret
- In Google Cloud, go to APIs & Services -> Credentials.
- Click Create credentials -> OAuth client ID.
- Choose Web application.
- Give it a name, for example
Slot. - Add the public origin for your deployed Slot URL:
Authorized JavaScript origin:
https://book.example.com
- Add the callback URL for the same deployed Slot URL:
Authorized redirect URI:
https://book.example.com/auth/google/callback
- Click Create.
- Copy the generated Client ID and Client secret.
- Use those two values when
npm run setupasks for:
Google OAuth Client ID
Google OAuth Client Secret
If you change the public domain later, update the OAuth client redirect URI to match the new domain exactly.
Install:
npm install
npm run setup
npm run setup will:
- use your normal interactive Wrangler login
- build the Worker and frontend assets
- provision or reuse the D1 database
- ask for the public URL, admin Google account, Google Client ID and Client Secret
- generate cryptographic application secrets locally
- upload the secrets to the Worker
- apply the database migration
- deploy the Worker and static assets
When setup asks for the public URL, enter the domain where this Worker will be reached, for example:
https://book.example.com
Configure the Google OAuth client with values derived from that same URL:
Authorized JavaScript origin:
https://book.example.com
Authorized redirect URI:
https://book.example.com/auth/google/callback
After setup finishes, attach your custom domain to the Worker in Cloudflare Workers & Pages if you are not using the generated workers.dev URL. Then open:
https://book.example.com/admin
Sign in with the email entered during setup. The first authorization requests offline Calendar access so the Worker can check availability and create bookings while you are not present.
Stripe payments
Stripe itself is optional. If payments are disabled, Slot confirms bookings immediately as before. If payments are enabled, Slot can charge only the meeting lengths you want: selected durations can be marked free while the others require Stripe Checkout before confirmation.
This integration uses Stripe-hosted Checkout, so there is no publishable key or card form in the React app. The Stripe secret key stays in the Worker only.
- In Stripe, create or use the account that should receive booking payments.
- Add the Stripe secret key to the Worker:
npx wrangler secret put STRIPE_SECRET_KEY
- Create a Stripe webhook endpoint pointing to:
https://book.example.com/api/stripe/webhook
Subscribe it to these Checkout events:
checkout.session.completed
checkout.session.async_payment_succeeded
checkout.session.async_payment_failed
checkout.session.expired
refund.created
refund.updated
refund.failed
- Copy the webhook signing secret (it starts with
whsec_) into the Worker:
npx wrangler secret put STRIPE_WEBHOOK_SECRET
- Apply the payment migration and deploy:
npm run db:migrate:remote
npm run deploy
- Open
/admin→ Settings → Stripe payments. Choose the currency, set a price per minute in normal currency units, choose the checkout label, then turn on Enable Stripe payments. Under Free durations, select any meeting lengths that should bypass Stripe and confirm normally.
The Stripe settings card also shows TEST MODE for sk_test_... keys and LIVE MODE for sk_live_... keys. The secret key itself is never sent to the browser.
For example, 50 with sek means SEK 50 per minute, so a 15-minute booking costs SEK 750. Slot converts that amount to Stripe minor units internally.
Upgrading from v0.1 intentionally disables the old fixed-amount payment toggle until you save a per-minute rate. The old fixed amount cannot be safely guessed as a per-minute price.
When Checkout starts, Slot creates a private owner-only calendar hold so nobody else can take the time. The visitor receives no invitation and no Meet link yet. After Stripe reports the payment as paid, Slot converts that hold into the real attendee event and sends the invitation. Cancelling or letting Checkout expire releases the hold.
Checkout is limited to card-based payment methods because appointment inventory cannot safely remain reserved for delayed bank-payment methods. Payment status is stored on the booking and shown in admin.
Cancelling a paid confirmed booking from Slot Admin issues a full Stripe refund against the original PaymentIntent. Slot will not silently cancel a paid booking if the currently configured Stripe key cannot access the environment that took the payment. Refund state is stored on the booking and updated by Stripe refund webhooks when those events are enabled. Refunds always go back to the original payment method.
Booking management
Open a confirmed future booking in /admin to manage its time:
- Reschedule moves the existing booking to another currently available start time. The duration and payment stay unchanged. Google Calendar sends the attendee an updated invitation.
- Suggest new time leaves the original booking untouched and sends a Google Calendar update containing a secure acceptance link. The suggested time is not held, so Slot rechecks availability when the attendee accepts it. If somebody else takes it first, the attendee is asked to contact the organizer instead of causing a double booking.
- Cancel meeting cancels the Google Calendar event. If the booking was paid through Stripe, Slot first creates a full refund to the original payment method.
Suggestion links contain a high-entropy bearer token. Slot stores only a SHA-256 hash of that token in D1, and the link becomes invalid when the suggestion is accepted, superseded, the booking is rescheduled directly, or the booking is cancelled.
Paid reschedules keep the original charge because the meeting duration does not change. Slot does not create a second Checkout Session for a pure time change.
Visitors can manage their own future confirmed booking from the public booking page. Each confirmation has a high-entropy SLT-... booking ID, also included in the Google Calendar invitation. Entering that ID under Manage a booking opens the booking and offers Reschedule or Cancel. Rescheduling keeps the same duration and payment state and sends an updated Google Calendar invitation. Paid cancellations are refunded through Stripe before the calendar event is cancelled. Public management attempts are rate-limited.
Activity
Open /admin -> Activity for an all-time operational snapshot and recent lifecycle feed. The dashboard tracks:
- total bookings, upcoming meetings, completed meetings, cancellations, reschedules, paid bookings, and average meeting length
- when payments are enabled: open payment holds plus gross Stripe revenue, refunds, and net revenue, kept separate by currency if the configured currency changes over time
- recent booking confirmations, Checkout starts, payments, released holds, reschedules, suggested times, accepted suggestions, refunds, and cancellations
Activity events are stored separately from the booking rows so repeated reschedules and payment/refund changes are preserved as history. Migration 0005_activity.sql backfills the lifecycle points that can be safely reconstructed from existing bookings. Activity logging is observational: a feed-write failure never blocks a booking, payment, refund, cancellation, or calendar update.
Attendees and booking search
Open /admin -> Attendees to see repeat-booking history grouped by normalized attendee email. Each attendee shows total bookings, upcoming/completed meetings, cancellations, reschedules, booked minutes, average duration, and (when Stripe payments are enabled) paid/refunded booking counts. Opening an attendee shows their recent booking history and links back into the normal booking detail view.
The Bookings tab also has server-backed search. Search by the public SLT-... booking ID, internal booking UUID, attendee name, or attendee email; results can still be switched between Upcoming and Past. Search is not limited to the initially loaded booking list.
Google project in Testing mode
If the Google OAuth audience is External / Testing, add the admin account as a test user. Google may expire testing-mode authorizations, so move the OAuth app to the appropriate production state when you are satisfied with it.
Later deploys
npm run deploy
Apply new D1 migrations when a release adds them:
npm run db:migrate:remote
Local development
Copy .dev.vars.example to .dev.vars and fill in development credentials.
npm run db:migrate:local
npm run dev
For local Google OAuth, add the localhost callback you actually use to the OAuth client.
Important implementation details
Availability
Weekly availability is the outer boundary. Google Calendar is the source of truth for what is actually busy inside that boundary.
Booking race protection
Before creating a Google event, the Worker reserves all small time segments touched by the booking (including configured buffers) in D1. Those inserts are submitted as one D1 batch. A uniqueness collision causes the reservation to fail instead of allowing two concurrent bookings to win the same time.
The Worker then asks Google FreeBusy one final time before creating the event.
Google tokens
The Google refresh token is encrypted with AES-GCM before it is persisted in D1. TOKEN_ENCRYPTION_KEY stays in Cloudflare Worker secrets and is not stored in the database or source tree.
Current limits
This is a focused first version, not a Cal.com clone.
- One owner/admin account
- Guest self-service management is booking-ID based rather than account based; the booking ID acts as a high-entropy bearer reference
- No recurring availability exceptions UI yet (block time in Google Calendar instead)
- Admin currently accepts calendar IDs as text rather than fetching a calendar picker
- No custom reminder emails; Google Calendar sends the invitation
Those are deliberate cuts to keep deployment and operation small.
Architecture
Architecture
Visitor
│
▼
your-booking-domain.example
│
├── static React UI ───────────────┐
│ │ one Worker deployment
└── /api/* Cloudflare Worker ──────┘
│
├── D1
│ settings
│ bookings
│ OAuth token (AES-GCM encrypted)
│ OAuth state
│ short-lived slot locks
│ basic rate limits
│ Stripe payment/refund state
│ reschedule proposals (hashed bearer tokens)
│ append-only admin activity events
│
├── Google Calendar API
│ FreeBusy
│ Event creation
│ Google Meet creation
│ attendee invitation
│
└── Stripe API
Checkout Sessions
PaymentIntent refunds
signed payment/refund webhooks
Booking transaction
- Calculate candidate times inside weekly availability.
- Subtract Google FreeBusy ranges and recent D1 reservations.
- Visitor chooses a start and duration.
- Server recomputes availability rather than trusting the browser.
- D1 reserves each time segment touched by the booking and buffers.
- Google FreeBusy is checked once more.
- If payment is disabled, the normal Google event is created and the booking is confirmed immediately.
- If payment is enabled, Slot creates a private owner-only Google Calendar hold with no attendee invitation. A Google Meet conference may be attached to the hold internally, but it is not exposed to the visitor.
- Stripe Checkout opens for the calculated duration × per-minute rate. The Checkout Session expires after 30 minutes.
- A signed Stripe webhook is the primary fulfillment signal. Only after
payment_status=paiddoes Slot convert the private hold into the attendee event, send Google Calendar updates, expose the Meet link, and mark the booking confirmed. - Cancelled or expired Checkout sessions release the private hold and D1 locks. A return-page status check provides an idempotent fallback if the webhook and browser redirect arrive in the opposite order.
Why D1 exists
Google Calendar owns calendar truth. D1 only holds application state Google does not provide: UI settings, booking metadata, encrypted OAuth credentials, concurrency locks, payment/refund state, and the admin activity history.
Booking management
A direct admin reschedule acquires short-lived D1 locks for the destination, rechecks Google FreeBusy, PATCHes the existing Google Calendar event with sendUpdates=all, and then moves the D1 booking record. The booking ID and Stripe payment remain the same.
A suggested new time does not move or release the original booking. Slot stores the proposed start/end plus a SHA-256 hash of a random bearer token, then PATCHes the Google event description with a secure acceptance URL using sendUpdates=all. When the attendee accepts, Slot re-runs normal availability checks before moving the existing event.
For a paid cancellation, Slot creates an idempotent full refund using the stored Stripe PaymentIntent before finalizing cancellation. Refund webhook events keep the D1 refund status current. If the configured Stripe mode does not match the payment's Checkout Session mode, Slot refuses the paid cancellation rather than cancelling without a safe refund path.
Every booking also has a separate high-entropy public booking ID (SLT-...). It is shown to the attendee on confirmation and written into the Google Calendar event description. The public cancellation endpoint accepts only this reference, rate-limits attempts, requires a future confirmed booking, and then delegates to the same cancellation/refund function used by Admin. The internal UUID remains the relational key and is not used as the guest-facing cancellation reference.
Activity history
Slot writes lifecycle events to activity_events after successful booking, payment, reschedule, suggestion, refund, cancellation, and payment-hold transitions. Idempotency keys suppress duplicate webhook events. Activity writes are deliberately non-blocking so analytics can never make a booking or refund fail. The Admin Activity endpoint computes aggregate booking metrics from the booking table and reads the latest activity events for the chronological feed. Monetary totals stay grouped by currency.
Optional paid durations
Stripe is optional. When Stripe payments are enabled, paymentFreeDurations can mark selected configured meeting lengths as free. Booking creation decides whether Checkout is required from the selected duration. Free bookings take the normal immediate-confirmation path; paid bookings use the Stripe hold/Checkout/fulfillment path.
Public booking management
Confirmed bookings expose a high-entropy public SLT-... reference. The public Manage booking flow uses that bearer reference to load limited booking metadata and lets the attendee cancel or reschedule an upcoming confirmed booking. Rescheduling preserves duration and existing payment state, rechecks live availability, and updates the existing Google Calendar event. Public management endpoints are rate-limited.
LICENSE
MIT License
Copyright (c) 2026 Alex Nord
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.