
Punctual
Team booking pages with calendar sync, availability rules and scheduling APIs.
Punctual is a self-hosted Cal.com/Calendly alternative built on Cloudflare (D1, Durable Objects, KV, Queues, R2). Free tier eligible within limits. Inspect the source and license in the linked repository.
Source & license
Upstream license: MIT
License TL;DR
You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.
Explain MIT in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Free tier eligible within limits
The reviewed Cloudflare deployment is eligible for Free-plan allowances for the stated small workload and feature scope. Usage limits, CPU, required account setup and separate services apply.
Hosting requirements
- Free hosting scope includes booking pages and console email; guest invitations/reminders are not delivered until an email provider is configured.
- Use your own Google/Microsoft OAuth apps and calendar accounts; third-party account/service costs are not included.
- Keep D1/KV/R2/SQLite DO within Free quotas, Workers CPU under 10 ms, and Queues below 10,000 operations/day (send/read/delete plus retries).
- Cloudflare native email to guests requires Workers Paid and an onboarded sending domain; Resend/Brevo have separate pricing that has not been reviewed.
- Replace placeholder database/KV IDs, configure signing/encryption secrets and BASE_URL, and complete calendar/provider setup before real guest use.
- Workers Free dynamic requests are shared across this account (100,000/day), with 10 ms CPU per invocation; workload fit is conditional and has not been measured.
- D1 Free allowance: 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; unindexed scans and history retention consume quota.
- KV Free allowance: 100,000 keys read/day and 1,000 each writes/deletes/list requests/day; this may constrain updates before Worker request limits.
- R2 Standard allowance: 10 GB-month storage, 1 million Class A and 10 million Class B operations/month; account activation may require billing setup, and other storage classes are excluded.
- Only SQLite Durable Objects qualify for Workers Free. Keep DO requests below 100,000/day, active duration below 13,000 GB-s/day and SQLite storage/operations inside the captured allowances.
Sources checked 01/10/2026
Repository snapshot: b98efc6. Hosting eligibility reflects the deployment documentation and listed assumptions.
- calendly ↗
*Event types** with buffers, notice windows, horizons, daily caps, custom questions - **Teams** — round-robin and collective scheduling with required and optional hosts, each on their own schedule; team admins manage members, event types and each member's availability, and can add or swap hosts on a booking after the fact - **Calendar events that say who the meeting is with** — every participant named with their company, on the hosts' calendars and in the guest's invitation - **Branding** — a company logo and per-event-type logos, as a circle or in t
- cal-com ↗
ve scheduler — a full single-team alternative to Calendly that runs entirely on Cloudflare Workers. Booking pages render at the edge; self-hosting is one `wrangler deploy` into your own Cloudflare account, $0 on the free tier. > **Status:** [v0.1.1](https://github.com/CCCrafts/punctual/releases/tag/v0.1.1) > released and live. Pre-1.0, so interfaces may still change. ## What it looks like A booking page, rendered at the edge — no client-side spinner between the guest picking a day and seeing open times. ![Punctual booking page: a day picker calendar next to a
- workers ↗
#:schema node_modules/wrangler/config-schema.json name = "punctual" main = "src/index.ts" compatibility_date = "2025-08-23" compatibility_flags = ["nodejs_compat"] # The OSS deliverable is one Worker and one D1 (spec §8). Nothing below # requires a paid plan; a self-hoster runs `wrangler deploy` and is done. # Self-hosted OFL font files (docs/branding/brand.md), served directly by # Cloudflare's asset edge — never the Worker — so the brand typography loads # without a third-party font CDN call. Requests under /fonts/* are the only
- d1 ↗
ender. #[[send_email]] #name = "EMAIL" [observability] enabled = true [[d1_databases]] binding = "DB" database_name = "punctual" database_id = "<the id `wrangler d1 create punctual` printed>" migrations_dir = "migrations" # freeBusy cache (ADR-0006 §1) AND rendered OG card PNGs — two key # prefixes (`fb:`, `og:`) on one namespace, both non-authoritative and # re-derivable. Own bookings and holds are never stored here in either form — # KV propagates in "up to 60 seconds or more", which is unacceptable for the # writes users check immediately. [[kv_namespaces]]
- kv ↗
more", which is unacceptable for the # writes users check immediately. [[kv_namespaces]] binding = "CACHE" id = "<the id `wrangler kv namespace create CACHE` printed>" # User-uploaded, durable content — host avatars and team logos. # Deliberately its own bucket, not the CACHE namespace above: that KV # namespace is non-authoritative and re-derivable by construction (ADR-0006 # §1); an uploaded photo is neither. R2's free tier (10 GB storage, no # egress fee for Worker-served reads) keeps this inside the "$0 to start" # pledge the same way D1 and KV do. [[r2_bucke
- r2 ↗
) keeps this inside the "$0 to start" # pledge the same way D1 and KV do. [[r2_buckets]] binding = "AVATARS" bucket_name = "punctual-avatars" [[durable_objects.bindings]] name = "HOST_CALENDAR" class_name = "HostCalendar" [[durable_objects.bindings]] name = "RATE_LIMITER" class_name = "RateLimiter" [[migrations]] tag = "v1" new_sqlite_classes = ["HostCalendar", "RateLimiter"] # Emails and webhooks. Both degrade gracefully when absent: the engine falls # back to inline delivery so a self-hoster without Queues still works. [[queues.producers]] binding = "TASKS"
- durable-objects ↗
uckets]] binding = "AVATARS" bucket_name = "punctual-avatars" [[durable_objects.bindings]] name = "HOST_CALENDAR" class_name = "HostCalendar" [[durable_objects.bindings]] name = "RATE_LIMITER" class_name = "RateLimiter" [[migrations]] tag = "v1" new_sqlite_classes = ["HostCalendar", "RateLimiter"] # Emails and webhooks. Both degrade gracefully when absent: the engine falls # back to inline delivery so a self-hoster without Queues still works. [[queues.producers]] binding = "TASKS" queue = "punctual-tasks" [[queues.consumers]] queue = "punctual-tasks" max_batc
- queues ↗
ack to inline delivery so a self-hoster without Queues still works. [[queues.producers]] binding = "TASKS" queue = "punctual-tasks" [[queues.consumers]] queue = "punctual-tasks" max_batch_size = 10 max_batch_timeout = 5 max_retries = 5 dead_letter_queue = "punctual-tasks-dlq" # Reminders at 24h and 1h, plus hold expiry and lock pruning. [triggers] crons = ["*/5 * * * *"] # This file is the TEMPLATE a self-hoster edits: replace the two ids above # and every value below with your own. (Punctual's own deployment keeps its # real values in an untracked wrangler.pro
- free-tier-eligible ↗
#:schema node_modules/wrangler/config-schema.json name = "punctual" main = "src/index.ts" compatibility_date = "2025-08-23" compatibility_flags = ["nodejs_compat"] # The OSS deliverable is one Worker and one D1 (spec §8). Nothing below # requires a paid plan; a self-hoster runs `wrangler deploy` and is done. # Self-hosted OFL font files (docs/branding/brand.md), served directly by # Cloudflare's asset edge — never the Worker — so the brand typography loads # without a third-party font CDN call. Requests under /fo
- free-tier-eligible ↗
ount Manager. | | Requests<sup>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 second
- free-tier-eligible ↗
rs Paid](https://developers.cloudflare.com/workers/platform/pricing/#workers) | | --- | --- | --- | | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows | | Storage (per GB stored) | 5 GB (total) | First 5 GB included + $0.75 / GB-mo | Track your D1 usage To accurately track your usage, use the [meta object](https://developers.cloudflare.com/d1/worker-api/return-object/), [GraphQL Analytics API](https://developers.cloudflare.com/d1/obs
- free-tier-eligible ↗
flare.com/workers/platform/pricing/). | | Free plan<sup>1</sup> | Paid plan | | --- | --- | --- | | Keys read | 100,000 / day | 10 million/month, + $0.50/million | | Keys written | 1,000 / day | 1 million/month, + $5.00/million | | Keys deleted | 1,000 / day | 1 million/month, + $5.00/million | | List requests | 1,000 / day | 1 million/month, + $5.00/million | | Stored data | 1 GB | 1 GB, + $0.50/ GB-month | <sup>1</sup> The Workers Free plan includes limited Workers KV usage. All limits reset daily at 00:00 UTC. If you exceed any one of these limits, further o
- free-tier-eligible ↗
f you have retrieved data (for infrequent access storage) for 1.1 GB, you will be billed for 2 GB. ### Free tier You can use the following amount of storage and operations each month for free. | | Free | | --- | --- | | Storage | 10 GB-month / month | | Class A Operations | 1 million requests / month | | Class B Operations | 10 million requests / month | | Egress (data transfer to Internet) | Free <sup>[1](#user-content-fn-1)</sup> | Caution The free tier only applies to Standard storage, and does not apply to Infrequent Access storage. ### Storage usage S
- free-tier-eligible ↗
ute and storage. Note Durable Objects are available both on Workers Free and Workers Paid plans. - **Workers Free plan**: Only Durable Objects with [SQLite storage backend](https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/#create-sqlite-backed-durable-object-class) are available. - **Workers Paid plan**: Durable Objects with the SQLite storage backend are available. The [key-value storage backend](https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/#storage-backends) is only avail
- free-tier-eligible ↗
(egress) or throughput (bandwidth) charges. | | Workers Free | Workers Paid | | --- | --- | --- | | Standard operations | 10,000 operations/day included | 1,000,000 operations/month included + $0.40/million operations | | Message retention | 24 hours (non-configurable) | 4 days default, configurable up to 14 days | In most cases, it takes 3 operations to deliver a message: 1 write, 1 read, and 1 delete. Therefore, you can use the following formula to estimate your monthly bill: ```txt ((Number of Messages * 3) - 1,000,000) / 1,000,000 * $0.40 ``` Additionall
- MIT ↗
MIT License Copyright (c) 2026 Sergey Bulaev 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
- architecture ↗
#:schema node_modules/wrangler/config-schema.json name = "punctual" main = "src/index.ts" compatibility_date = "2025-08-23" compatibility_flags = ["nodejs_compat"] # The OSS deliverable is one Worker and one D1 (spec §8). Nothing below # requires a paid plan; a self-hoster runs `wrangler deploy` and is done. # Self-hosted OFL font files (docs/branding/brand.md), served directly by # Cloudflare's asset edge — never the Worker — so the brand typography loads # without a third-party font CDN call. Requests under /fonts/* are the only
Upstream screenshot · CCCrafts/punctual repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Editorial workflow alternative: Single-team booking pages, event types, round-robin/collective scheduling and calendar coordination; no managed OAuth, support or whole-service parity claim.
See supporting source ↗Editorial workflow alternative: Self-managed team availability, booking rules, calendar integration and APIs; integration coverage and migration parity are unverified.
See supporting source ↗How it works
The shape of Punctual on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit b98efc609cce. Files were read as data; upstream applications and CI jobs were not executed.
Partial source coverage: 39 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.
Deployment configuration · 1 files
Cloudflare Workers · compatibility 2025-08-23
punctual · default
Entrypoint: src/index.ts
Static assets: ./assets
Cron triggers (UTC): */5 * * * *
DB→ D1CACHE→ KVAVATARS→ R2HOST_CALENDAR→ Durable Objects · class HostCalendarRATE_LIMITER→ Durable Objects · class RateLimiterTASKS→ Queues (producer) · queue punctual-taskspunctual-tasks→ Queues (consumer) · queue punctual-tasks · dead letters punctual-tasks-dlqStatic assets→ Static assets
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L211 · fetch handler exported · calls createEngine, buildPorts, engine.fetch
- L216 · queue handler exported · calls buildPorts, console.error, m.retry, handleQueueBatch
- L237 · scheduled handler exported · calls ctx.waitUntil, catch, runScheduledTasks, buildPorts, console.error
- L68 · buildPorts calls (conditional paths may differ): baseUrl.includes, createWebCrypto, createEnvOAuthCredentials, createKvCache, createKvBlobCache, createR2BlobStorage, Date.now, createD1Repositories, createCalendarProviders, repos.connections.byId, crypto_.encrypt, JSON.stringify, repos.connections.updateTokens, selectEmailDelivery, createCloudflareSender, createResendSender, createBrevoSender, createConsoleSender, console.warn, createQueueAdapter
Environment references: env.BASE_URL · env.ENCRYPTION_KEY_V1 · env.ENCRYPTION_KEY_V2 · env.SIGNING_KEY · env.CACHE · env.AVATARS · env.DB · env.FROM_EMAIL · env.FROM_NAME · env.EMAIL · env.RESEND_API_KEY · env.BREVO_API_KEY · env.TASKS · env.RATE_LIMITER · env.BRAND_NAME · env.LEGAL_OPERATOR · env.DEMO_BOOKING_PATH · env.GA_MEASUREMENT_ID · env.SIGNUPS · env.SUPPORT_EMAIL · env.TELEMETRY_ENABLED · env.HOST_CALENDAR
- L38 · createEngine calls (conditional paths may differ): createSlotService, buildRouter, app.fetch
- L65 · resolveSchedule calls (conditional paths may differ): repos.availability.forUser, repos.availability.byId
- L83 · createSlotService calls (conditional paths may differ): ports.repositories, hostUsers.map, bookingFootprint, Promise.all, repos.slotLocks.busyBuckets, repos.slotLocks.activeHolds, ports.clock.now, hostSettings, settings.get, resolveSchedule, externalBusyFor, perDayCounts, combineBusy, busyByHost.get, holdsByHost.get, built.filter, computeSlots
- L159 · externalBusyFor calls (conditional paths may differ): repos.connections.listForUser, partitionConnections, ports.cache.get, out.push, ports.calendars.get, provider.getBusy, ports.cache.put, needsReconnect, catch, repos.connections.updateSyncStatus
- L197 · perDayCounts calls (conditional paths may differ): repos.bookings.listForHost, localDateString, counts.set, counts.get
- L225 · daysWithSlots calls (conditional paths may differ): map.set, localDateString
- L232 · monthRange calls (conditional paths may differ): map, month.split, padStart, String, Date.UTC, nextMonth.getUTCFullYear, nextMonth.getUTCMonth, localTimeToInstant
- L244 · dayRange calls (conditional paths may differ): map, date.split, Date.UTC, next.getUTCFullYear, padStart, String, next.getUTCMonth, next.getUTCDate, localTimeToInstant
- L64 · createD1Repositories calls (conditional paths may differ): db.withSession, bind, session.prepare, all, q, first, run, mapUser, email.toLowerCase, filter, rows.map, Date.now, session.batch, row.email.toLowerCase, isConstraintViolation, sets.push, binds.push, sets.join, mapEventType, mapTeam
- L1563 · groupBuckets calls (conditional paths may differ): out.get, list.push, out.set
- L1578 · isConstraintViolation calls (conditional paths may differ): String, msg.includes
- L1588 · mapSchedule calls (conditional paths may differ): String, Number, JSON.parse
- L1602 · mapUser calls (conditional paths may differ): String, Number
- L1619 · mapTeam calls (conditional paths may differ): String, Number
- L1631 · mapEventTypeHost calls (conditional paths may differ): String, Number
- L1642 · mapMember calls (conditional paths may differ): String, Number
- L1651 · mapEventType calls (conditional paths may differ): String, Number, JSON.parse
- L1679 · mapBooking calls (conditional paths may differ): String, JSON.parse, Number
- L1704 · mapConnection calls (conditional paths may differ): String, Number, JSON.parse
- L1720 · mapApiKey calls (conditional paths may differ): String, JSON.parse, Number
- L1734 · mapWebhook calls (conditional paths may differ): String, JSON.parse, Number
- L34 · createWebCrypto calls (conditional paths may differ): aesKeys.get, Promise.reject, crypto.subtle.importKey, decodeBase64, aesKeys.set, crypto.getRandomValues, aesKey, crypto.subtle.encrypt, encoder.encode, out.set, encodeBase64Url, crypto.subtle.decrypt, raw.subarray, decoder.decode, signingCryptoKey, crypto.subtle.sign, crypto.subtle.verify, crypto.subtle.digest, toHex
- L53 · aesKey calls (conditional paths may differ): aesKeys.get, Promise.reject, crypto.subtle.importKey, decodeBase64, aesKeys.set
- L70 · signingCryptoKey calls (conditional paths may differ): crypto.subtle.importKey, decodeBase64
- L153 · encodeBase64Url calls (conditional paths may differ): String.fromCharCode, replace, btoa
- L164 · decodeBase64 calls (conditional paths may differ): replace, value.replace, repeat, atob, binary.charCodeAt
- L173 · toHex calls (conditional paths may differ): padStart, toString
- L27 · createKvCache calls (conditional paths may differ): kv.get, kv.put, JSON.stringify, Math.max, Math.floor, kv.delete
- L20 · createKvBlobCache calls (conditional paths may differ): kv.get, kv.put, Math.max, Math.floor
- L13 · createR2BlobStorage calls (conditional paths may differ): bucket.get, obj.arrayBuffer, bucket.put
- L24 · createResendSender calls (conditional paths may differ): formatAddress, sanitizeHeader, message.attachments.map, fetch, JSON.stringify, catch, res.text, trim
- L70 · createConsoleSender calls (conditional paths may differ): map, console.log
- L97 · formatAddress calls (conditional paths may differ): sanitizeHeader, replace
- L126 · createBrevoSender calls (conditional paths may differ): globalThis.fetch.bind, sanitizeHeader, message.attachments.map, doFetch, JSON.stringify, catch, res.text, detail.slice
- L213 · createCloudflareSender calls (conditional paths may differ): sanitizeHeader, opts.binding.send, message.attachments.map, String, test, trim
- L44 · selectEmailDelivery calls (conditional paths may differ): toLowerCase, trim, inferred, includes, EMAIL_PROVIDERS.join, credential.has
Environment references: env.RESEND_API_KEY · env.BREVO_API_KEY · env.EMAIL · env.EMAIL_PROVIDER
- L109 · createEnvOAuthCredentials calls (conditional paths may differ): baseUrl.replace, pair
- L224 · isApiNotEnabled calls (conditional paths may differ): test
- L306 · loadTokens calls (conditional paths may differ): deps.crypto.decrypt, tokenAad, JSON.parse, isRecord
- L328 · ensureAccessToken calls (conditional paths may differ): loadTokens, deps.clock.now, refreshTokens, deps.onTokensRefreshed
- L351 · refreshTokens calls (conditional paths may differ): deps.oauth.forProvider, conn.provider.toUpperCase, form.set, MICROSOFT_CALENDAR_SCOPES.join, globalThis.fetch.bind, doFetch, form.toString, res.text, safeJson, isRecord, truncate, deps.clock.now
- L442 · providerFetch calls (conditional paths may differ): globalThis.fetch.bind, doFetch, send, ensureAccessToken
- L464 · expectOk calls (conditional paths may differ): truncate, res.text, isApiNotEnabled, test
- L489 · readJson calls (conditional paths may differ): expectOk, res.text, safeJson, truncate
- L506 · isRecord calls (conditional paths may differ): Array.isArray
- L510 · safeJson calls (conditional paths may differ): JSON.parse
- L519 · truncate calls (conditional paths may differ): text.trim, trimmed.slice
Environment references: env.GOOGLE_CLIENT_ID · env.GOOGLE_CLIENT_SECRET · env.MICROSOFT_CLIENT_ID · env.MICROSOFT_CLIENT_SECRET
- L17 · createCalendarProviders calls (conditional paths may differ): createGoogleProvider, createMicrosoftProvider, ORDER.filter, deps.oauth.forProvider, available.includes, name.toUpperCase, available.slice
- L35 · createCoordinator calls (conditional paths may differ): deps.hostCalendarNamespace.get, deps.hostCalendarNamespace.idFromName, deps.repositories, ports.clock.now, request.guestEmail.toLowerCase, ports.crypto.hash, JSON.stringify, repos.idempotency.reserve, repos.bookings.byId, JSON.parse, Boolean, ports.crypto.randomToken, repos.eventTypes.byId, sort, acquireLease, stub, acquired.push, bookingFootprint, hostSettings, buildHostInputs
- L399 · buildHostInputs calls (conditional paths may differ): ports.clock.now, Promise.all, repos.slotLocks.busyBuckets, repos.slotLocks.activeHolds, repos.users.byId, resolveSchedule, settings.get, repos.connections.listForUser, partitionConnections, external.push, getBusy, ports.calendars.get, needsReconnect, catch, repos.connections.updateSyncStatus, localDateString, repos.bookings.countForHostOnDate, dayRange, out.push, combineBusy
- L19 · createQueueAdapter calls (conditional paths may differ): queue.send, queue.sendBatch, messages.map, inline, console.warn
- L13 · createRateLimiterAdapter calls (conditional paths may differ): namespace.get, namespace.idFromName, stub.consume, Date.now
- L17 · handleQueueBatch calls (conditional paths may differ): handleOne, message.ack, console.error, message.retry
- L31 · handleOne calls (conditional paths may differ): ports.email.send, deliverWebhook, syncCalendar
- L54 · deliverWebhook calls (conditional paths may differ): ports.repositories, repos.webhooks.byId, JSON.stringify, Math.floor, ports.clock.now, hmacHex, fetch, String
- L82 · hmacHex calls (conditional paths may differ): crypto.subtle.importKey, encode, crypto.subtle.sign, join, map, padStart, b.toString
- L118 · syncCalendar calls (conditional paths may differ): ports.repositories, repos.bookings.byId, repos.eventTypes.byId, Object.entries, repos.connections.byId, deleteEvent, ports.calendars.get, console.error, needsReconnect, catch, repos.connections.updateSyncStatus, JSON.stringify, repos.bookings.setExternalEventIds, planInvites, calendarTitle, calendarDescription, plan.organizerTz.get, externalFor, createEvent, freshlyCreated.push
- L369 · planInvites calls (conditional paths may differ): hostSettings, repos.users.byId, filter, repos.connections.listForUser, hosts.push, participantsFor, hosts.map, settings.get, hosts.flatMap, h.writable.map, hosts.filter, providers.forEach, hosts.find, h.writable.some, organizer.writable.find, booking.guestEmail.toLowerCase, email.toLowerCase, seen.has, seen.add, attendees.push
- L425 · legacyAttendees calls (conditional paths may differ): repos.users.byId
- L460 · dispatchConfirmation calls (conditional paths may differ): ports.repositories, repos.bookings.byId, repos.eventTypes.byId, repos.users.byId, filter, Promise.all, booking.hostUserIds.map, repos.bookings.claimConfirmation, ports.clock.now, notifyBookingRescheduled, notifyBookingCreated, catch, repos.bookings.releaseConfirmationClaim
- L15 · runScheduledTasks calls (conditional paths may differ): expireHolds, pruneLocks, sendReminders, tasks.push, sendTelemetry, console.error
- L39 · expireHolds calls (conditional paths may differ): ports.repositories, repos.slotLocks.expireHolds
- L51 · pruneLocks calls (conditional paths may differ): ports.repositories, repos.slotLocks.pruneLocksBefore
- L63 · sendReminders calls (conditional paths may differ): ports.repositories, repos.bookings.dueBetween, Promise.all, repos.eventTypes.byId, repos.users.byId, bookingReminder, ports.queue.send
- L115 · sendTelemetry calls (conditional paths may differ): getUTCHours, getUTCMinutes, ports.repositories, repos.telemetryCounts, catch, fetch, JSON.stringify
- L71 · parseSignupPolicy calls (conditional paths may differ): toLowerCase, trim, filter, map, value.split, e.trim
- L83 · signupAllowed calls (conditional paths may differ): normaliseEmail, normalised.slice, normalised.lastIndexOf, policy.entries.some, entry.startsWith
- L122 · requestMagicLink calls (conditional paths may differ): normaliseEmail, isPlausibleEmail, limitFor, deps.rateLimiter.check, rateLimited, deps.repos.users.byEmail, signupAllowed, deps.crypto.randomToken, deps.crypto.hash, validTimeZoneOrNull, deps.repos.sessions.createMagicLink, trimTrailingSlash, encodeURIComponent, deps.email.send, magicLinkText, magicLinkHtml
- L197 · consumeMagicLink calls (conditional paths may differ): deps.crypto.hash, deps.repos.sessions.consumeMagicLink, normaliseEmail, deps.repos.users.byEmail, validTimeZoneOrNull, signupAllowed, deps.repos.users.count, deps.repos.users.create, deps.crypto.randomToken, defaultNameFrom, uniqueSlug, deps.repos.availability.saveIfAbsent, defaultSchedule, createSession
- L302 · createSession calls (conditional paths may differ): deps.crypto.randomToken, deps.crypto.hash, deps.repos.bookmark, deps.repos.sessions.create
- L335 · validateSession calls (conditional paths may differ): deps.crypto.hash, deps.repos.sessions.byIdHash, isSessionValid, deps.repos.sessions.delete, deps.repos.users.byId, shouldSlideSession, nextSessionExpiry, deps.repos.sessions.touch, deps.repos.bookmark
- L364 · revokeSession calls (conditional paths may differ): deps.repos.sessions.delete, deps.crypto.hash
- L370 · revokeAllSessions calls (conditional paths may differ): deps.repos.sessions.deleteAllForUser
- L389 · createApiKey calls (conditional paths may differ): generateApiKey, deps.crypto.randomToken, deps.crypto.hash, apiKeyHashInput, deps.repos.apiKeys.create
- L421 · authenticateApiKey calls (conditional paths may differ): parseApiKey, deps.repos.apiKeys.byPrefix, deps.crypto.hash, apiKeyHashInput, constantTimeEqual, deps.repos.users.byId, deps.repos.apiKeys.touchLastUsed
- L466 · issueManageToken calls (conditional paths may differ): deps.crypto.randomToken, deps.crypto.sign, manageTokenPayload, formatManageToken, deps.crypto.hash
- L502 · verifyManageToken calls (conditional paths may differ): parseManageToken, deps.crypto.verify, deps.repos.bookings.byManageToken, deps.crypto.hash
- L527 · normaliseEmail calls (conditional paths may differ): toLowerCase, trim
- L532 · isPlausibleEmail calls (conditional paths may differ): test
- L549 · rateLimited calls (conditional paths may differ): Math.max, Math.ceil
- L553 · trimTrailingSlash calls (conditional paths may differ): url.endsWith, url.slice
- L557 · validTimeZoneOrNull calls (conditional paths may differ): isValidTimeZone
- L561 · defaultNameFrom calls (conditional paths may differ): email.split, trim, local.replace
- L574 · defaultSchedule calls (conditional paths may differ): defaultAvailability
- L583 · uniqueSlug calls (conditional paths may differ): suggestSlug, deps.repos.users.bySlug, deps.repos.teams.bySlug, validateSlug, taken, replace, toLowerCase, deps.crypto.randomToken
- L612 · magicLinkText calls (conditional paths may differ): join
- L625 · magicLinkHtml calls (conditional paths may differ): join, escapeHtml
- L637 · escapeHtml calls (conditional paths may differ): replace, value.replace
- L62 · app.get("/health")
- L87 · app.get("/")
- L121 · app.get("/docs")
- L131 · app.get("/docs/self-hosting")
- L141 · app.get("/docs/api")
- L151 · app.get("/docs/mcp")
- L161 · app.get("/calendly-alternative")
- L174 · app.route("/api/v1")
- L176 · app.route("/mcp")
- L177 · app.route("/")
- L183 · app.route("/")
- L200 · app.get("/privacy")
- L211 · app.get("/terms")
- L230 · app.get("/robots.txt")
- L253 · app.get("/favicon.svg")
- L263 · app.route("/")
- L268 · app.route("/")
- L273 · app.get("/:userSlug/:eventSlug")
- L425 · app.get("/:userSlug/:eventSlug/confirm")
- L468 · app.post("/:userSlug/:eventSlug/confirm")
- L52 · buildRouter calls (conditional paths may differ): app.get, warnings.push, c.json, ports.repositories, repos.settings.getMany, parseHomeSettings, withTeamPeople, homeItems, repos.eventTypes.listActiveWithOwners, homeFeatured, c.html, instanceHomePage, companyLogoFrom, homeGroups, landingPage, docsIndexPage, docsSelfHostingPage, docsApiPage, docsMcpPage, calendlyAlternativePage
- L623 · resolveGuestTimezone calls (conditional paths may differ): isValidTimeZone
- L630 · validMonth calls (conditional paths may differ): test
- L648 · clampMonth calls (conditional paths may differ): map, horizonAnchorMonth.split, Date.UTC, Math.ceil, Math.max, last.getUTCFullYear, padStart, String, last.getUTCMonth
- L656 · validDate calls (conditional paths may differ): test
- L661 · notFound calls (conditional paths may differ): c.html, shellHead, errorPage, shellFoot
- L691 · bookingPageRateLimited calls (conditional paths may differ): c.req.header, ports.rateLimiter.check, c.html, shellHead, errorPage, shellFoot, String, Math.ceil, ports.clock.now
- L92 · freeIntervalsForHost calls (conditional paths may differ): availability.overrides.map, localDatesBetween, overrides.has, overrides.get, dayOfWeek, localTimeToInstant, windows.push, mergeIntervals
- L131 · candidatesInWindow calls (conditional paths may differ): Math.ceil, out.push
- L163 · freeByHost calls (conditional paths may differ): map.set, freeIntervalsForHost
- L178 · computeSlots calls (conditional paths may differ): freeByHost, query.hosts.filter, required.map, free.get, intersectAll, candidatesInWindow, exceedsDailyCap, bookingFootprint, required.some, overlapsAny, optional.filter, isSlotStillValid, slots.push, map, dedupeByStart, capMap.get, localDateString, byStart.get, existing.eligibleHostIds.includes, existing.eligibleHostIds.push
- L272 · exceedsDailyCap calls (conditional paths may differ): localDateString
- L285 · dedupeByStart calls (conditional paths may differ): seen.has, seen.set, sort, Array.from, seen.values
- L317 · isSlotStillValid calls (conditional paths may differ): bookingFootprint, localDateString, freeIntervalsForHost, free.some, overlapsAny
- L85 · pickRoundRobinHost calls (conditional paths may differ): members.map, sort, Math.max, byId.get, lastAssignedAt.get
- L119 · failureReason calls (conditional paths may differ): bookingFootprint, host.busy.some
- L136 · prepareBooking calls (conditional paths may differ): req.hosts.filter, isSlotStillValid, failureReason, optional.filter, req.hosts.every, pickRoundRobinHost, eligible.map, eligible.find, bookingFootprint, intervalToBuckets, buckets.push, participating.map, req.guestEmail.toLowerCase, localDateString
- L240 · combineBusy calls (conditional paths may differ): external.slice, out.push
- L270 · normalizeQuestionLabel calls (conditional paths may differ): replace, toLowerCase, label.trim
- L282 · slugify calls (conditional paths may differ): slice, replace, value.toLowerCase
- L308 · effectiveQuestions calls (conditional paths may differ): et.questions.some, normalizeQuestionLabel
- L344 · submittedAnswerFor calls (conditional paths may differ): questions.some, normalizeQuestionLabel, claimed, direct.trim, alias.trim
- L372 · pickDeclaredAnswers calls (conditional paths may differ): effectiveQuestions, submittedAnswerFor, v.trim
- L407 · answeredQuestions calls (conditional paths may differ): effectiveQuestions, submittedAnswerFor, value.trim, out.push
- L420 · validateAnswers calls (conditional paths may differ): effectiveQuestions, trim, submittedAnswerFor, q.options.includes
- L441 · isValidEmail calls (conditional paths may differ): test
- L451 · partitionConnections calls (conditional paths may differ): connections.filter
Build and deployment pipeline · 1 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: push, pull_request
test · no job dependencies declared
- actions/checkout@v4
actions/checkout@v4 - actions/setup-node@v4
actions/setup-node@v4 - Shell command
npm ci - Shell command
npm run typecheck - Shell command
npm test - Shell command
npm ci - Shell command
npm run typecheck - Shell command
npm test - Shell command
npm run build
deploy: wrangler deploymigrate: wrangler d1 migrations apply punctualmigrate:local: wrangler d1 migrations apply punctual --local
build: tsc -p tsconfig.jsonprepublishOnly: npm run build && npm test
Repository README
View original on GitHub ↗Full upstream document by @CCCrafts · README.md · snapshot b98efc6
Scheduling that shows up on time.
Punctual is an open, edge-native scheduler — a full single-team alternative to
Calendly that runs entirely on Cloudflare Workers. Booking pages render at the
edge; self-hosting is one wrangler deploy into your own Cloudflare account,
$0 on the free tier.
Status: v0.1.1 released and live. Pre-1.0, so interfaces may still change.
What it looks like
A booking page, rendered at the edge — no client-side spinner between the guest picking a day and seeing open times.

The pledge
The open-source version is complete for a single team — forever. No seat limits, no gated scheduling features, no "non-production use" clauses. MIT makes this promise irrevocable: what we ship can never be taken back.
Quick start
One command, if you have Node 20+ and a Cloudflare account:
npx punctual-sh init
It clones this repo, creates the D1/KV/R2/Queues resources, writes their ids
into wrangler.toml, generates secrets, migrates and deploys. Or do the same
by hand:
git clone https://github.com/CCCrafts/punctual.git
cd punctual && npm install
npx wrangler login
npx wrangler d1 create punctual # put the id in wrangler.toml
npx wrangler kv namespace create CACHE # put the id in wrangler.toml
npx wrangler r2 bucket create punctual-avatars # put the name in wrangler.toml
openssl rand -base64 32 | npx wrangler secret put ENCRYPTION_KEY_V1
openssl rand -base64 32 | npx wrangler secret put SIGNING_KEY
npm run migrate
npm run deploy # prints your Worker URL — put it in wrangler.toml's BASE_URL
npm run deploy # again, so emailed links point at the real URL
Full walkthrough, including connecting Google and Microsoft calendars: docs/self-hosting.md.
What's here
- Booking pages rendered and streamed from the edge
- Google Calendar and Microsoft 365 sync, using your own OAuth app
- Event types with buffers, notice windows, horizons, daily caps, custom questions
- Teams — round-robin and collective scheduling with required and optional hosts, each on their own schedule; team admins manage members, event types and each member's availability, and can add or swap hosts on a booking after the fact
- Calendar events that say who the meeting is with — every participant named with their company, on the hosts' calendars and in the guest's invitation
- Branding — a company logo and per-event-type logos, as a circle or in their own proportions; social cards with the hosts' faces
- Emails with .ics invites, reschedule and cancel links, 24 h and 1 h reminders
- REST API, HMAC-signed webhooks, embed widget
- A built-in MCP server — your calendar as a tool an AI agent can call
How it works
Three decisions shape everything else.
Double-booking is impossible at the storage layer, not by convention.
Every booking writes one row per five-minute bucket per host into a
slot_locks table whose primary key is (host_user_id, bucket_start), in the
same D1 batch() as the booking itself. A conflicting bucket violates the
constraint and the whole batch rolls back. Range overlap can't be expressed as
a SQL constraint; discretised buckets can — that substitution is the design.
Durable Objects serialise attempts and re-check external calendars, but they
are the fast path, not the guarantee: if they misbehave the worst outcome is a
409.
All time is UTC internally; wall-clock exists only at the edges. Daylight saving is handled by explicit rules rather than by hope — a wall-clock time inside a spring-forward gap clamps forward, an ambiguous time during fall-back takes the first occurrence, and both rules apply uniformly to window starts and ends. The test matrix covers Kyiv, New York, Lord Howe (a 30-minute DST shift), Chatham (+12:45), Kolkata and the southern hemisphere, because whole-hour assumptions fail silently rather than loudly.
The engine takes everything through ports. Storage, calendars, email, crypto, cache, clock and queue all arrive as interfaces, so the same engine runs single-tenant for you and multi-tenant for a hosted service without a fork. There is deliberately no "limits" or "plan" interface anywhere in this repository — see the pledge.
Development
npm test # 874 tests
npm run test:core # pure domain only — no Workers runtime, runs in ~150ms
npm run typecheck
npm run dev
The domain layer has zero Cloudflare imports and runs under plain Node, which is what lets the timezone and slot-engine suites stay exhaustive and fast. Adapters, Durable Objects and HTTP are tested against the real Workers runtime with Miniflare.
License
MIT. A hosted version with managed OAuth apps, custom domains and zero setup is a separate commercial service running on this same engine.
Frequently asked about Punctual
What is Punctual?+
Punctual is a self-hosted Cal.com/Calendly alternative built on the Cloudflare developer platform. Team booking pages with calendar sync, availability rules and scheduling APIs.
What does Punctual replace?+
Punctual is listed as an alternative to Cal.com, Calendly. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does Punctual use?+
Punctual is built on D1, Durable Objects, KV, Queues, R2, Workers.
How much does Punctual cost to run?+
The reviewed Cloudflare deployment is eligible for Free-plan allowances for the stated small workload and feature scope. Usage limits, CPU, required account setup and separate services apply. Free hosting scope includes booking pages and console email; guest invitations/reminders are not delivered until an email provider is configured. Use your own Google/Microsoft OAuth apps and calendar accounts; third-party account/service costs are not included. Keep D1/KV/R2/SQLite DO within Free quotas, Workers CPU under 10 ms, and Queues below 10,000 operations/day (send/read/delete plus retries). Cloudflare native email to guests requires Workers Paid and an onboarded sending domain; Resend/Brevo have separate pricing that has not been reviewed. Replace placeholder database/KV IDs, configure signing/encryption secrets and BASE_URL, and complete calendar/provider setup before real guest use. Workers Free dynamic requests are shared across this account (100,000/day), with 10 ms CPU per invocation; workload fit is conditional and has not been measured. D1 Free allowance: 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; unindexed scans and history retention consume quota. KV Free allowance: 100,000 keys read/day and 1,000 each writes/deletes/list requests/day; this may constrain updates before Worker request limits. R2 Standard allowance: 10 GB-month storage, 1 million Class A and 10 million Class B operations/month; account activation may require billing setup, and other storage classes are excluded. Only SQLite Durable Objects qualify for Workers Free. Keep DO requests below 100,000/day, active duration below 13,000 GB-s/day and SQLite storage/operations inside the captured allowances. Check current Cloudflare pricing before deploying.
Is Punctual open source?+
The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/CCCrafts/punctual/b98efc609cce44718e7f68709fdf6df920dfc516/LICENSE. Source code and contributor credit are available at https://github.com/CCCrafts/punctual.


Discussion · 0
sign in to comment →