Public Booking Widget — Design Spec
Date: 2026-05-24 Status: Approved by user, ready for implementation plan Subsystem: Public API + Embed widget + In-app inbox Touches:lead_form_configs, lead_submissions, appointments, public routes, Cloudflare Pages, permission tree
1. Purpose
Ship a public booking widget that any OdontoX clinic can embed on its own website. Visitors pick a real, live slot from the clinic’s actual availability and submit a booking request. The request lands inside OdontoX as alead_submissions row (formType='booking'):
- Returning patients (phone matches an existing patient in the clinic): an
appointmentsrow is created inrequestedstatus linked to the existing patient. Reception can one-click Accept to schedule. - New people (no phone match): the lead sits in a Pending inbox. Reception clicks Accept → patient is created (MRN auto-assigned, no portal user, no PIN, no invite email) using the form data, and an appointment is created in
requestedstatus.
2. Non-goals (v1)
- Online payment / deposits.
- Patient self-cancel from the widget (manage-booking link via email is reserved for later; the existing
/public-appointments/respondflow already handles cancel/confirm with signed tokens). - Real-time slot push (SSE/WebSocket). Re-check at submit is sufficient.
- Multi-clinic booking on a single form. Each form is single-clinic; multi-branch clinics get multiple forms.
- Mobile-app integration. Widget is web-only.
- Doctor selection on the public form. v1 is “any available doctor”; reception assigns at Accept time.
3. System architecture
Three subsystems.3.1 Public API
Hono on Cloudflare Workers under/api/v1/public/booking/*. Three endpoints. All gated by X-OdontoX-Token header (per-form, rotatable), origin allowlist (per-form), invisible Cloudflare Turnstile, and rate limits. Pattern mirrors the existing public-leads.ts.
3.2 Embed widget
Small JS bundle (~25-40KB gzipped target) hosted on a new Cloudflare Pages project (booking-embed-app/) at https://embed.odontox.io/v1/booking.js. Drop-in <script> tag renders the form into the host page via Shadow DOM (CSS isolation without iframe friction). Calls only the public API; carries no secrets.
3.3 Inbox surfaces (in the existing OdontoX app)
Two views over the same underlyinglead_submissions table:
- Website Leads page — chronological log of every public-form submission (contact + booking). Existing page, extended.
- Appointments → Requests tab — action-oriented filter of pending booking submissions, with one-click Accept / Decline.
- Dashboard card — “Pending booking requests: N” → links to the Requests tab.
4. Data model (additive migration)
No new tables. Three additive changes to existing tables.4.1 lead_form_configs
BookingFormConfig shape (null for contact forms):
4.2 lead_submissions
Nullable booking-specific columns (null for contact submissions):
4.3 Indexes
4.4 Status semantics
lead_status_enum is unchanged. Lifecycle for booking submissions:
new— submitted, pending reception action (or, ifautoConfirmIfPhoneMatch=trueand phone matched, may already haveappointmentIdset but lead stillnewuntil reception confirms).converted— reception accepted;appointmentIdpopulated,convertedPatientIdpopulated.archived— reception declined for a non-spam reason.spam— reception flagged as spam (no patient email sent).contacted— reserved; existing flow for contact-form leads.
5. Public API surface
All endpoints live under/api/v1/public/booking/*. Mounted in server/src/api.ts. Common middleware order: CORS (origin allowlist echo) → token validation → tenant-status gate (per trial-gating memory) → rate-limit → handler.
5.1 GET /api/v1/public/booking/config
Headers: X-OdontoX-Token: <form_token>, Origin: <embed-origin>.
Response 200:
404 FORM_NOT_FOUND— token invalid / revoked / form inactive / clinic deleted.403 ORIGIN_NOT_ALLOWED— Origin header not inallowedOrigins.503 CLINIC_UNAVAILABLE— clinic on expired trial / suspended (trial-gating memory).
(formToken). No PII.
5.2 GET /api/v1/public/booking/available-slots?date=YYYY-MM-DD
Same headers.
Internals: calls calculateAvailableSlots({ clinicId, date, doctorId: null }) (union of all doctors), applies the existing 30-min buffer, additionally excludes any slot earlier than now + minHoursAhead and any date outside [today, today + maxDaysAhead].
Response 200:
(formToken, date). Freshness within ~1 min is fine because submit re-checks.
Rate limits: 20 req/min/IP, 300 req/min/form.
5.3 POST /api/v1/public/booking/submissions
Same headers, plus Idempotency-Key: <uuid> from the widget.
Body (zod-validated):
- Token + origin + clinic-status check (cached config). 403/404/503 on failure.
- Turnstile siteverify → 403
INVALID_TURNSTILEif fails. - Honeypot check — if non-empty, return
202with synthetic{ok:true,status:"received"}and short-circuit (no row inserted, no telemetry leak to attacker). - Body validation (zod + phone E.164 normalize). 400
INVALID_PAYLOADon failure. - Idempotency check —
SELECT … WHERE clinic_id=$1 AND idempotency_key=$2within 24h. If found, return the stored response (replay-safe). - Dedupe check — existing 5-min
dedupeHashupsert window. Same-hash repeat returns the original row. - Pending uniqueness —
SELECT COUNT(*) WHERE clinic_id=$1 AND formType='booking' AND status='new' AND phone_match_patient_id=$x OR (encrypted_phone=$norm). If >0, return 422DUPLICATE_PENDING. - Slot re-check —
calculateAvailableSlots({clinicId, date: requestedDate, doctorId: null}). IfrequestedTime ∉ slots, return 409SLOT_TAKENwith the fresh slot list in the response body so the widget can re-prompt. - Phone lookup — search
patients(this clinic, not deleted) by normalized phone. If matched →phoneMatchPatientIdis set on the lead. IfbookingConfig.autoConfirmIfPhoneMatch=true, immediately create theappointmentsrow inrequestedstatus linked to the matched patient; populateappointmentIdon the lead. - Insert
lead_submissionsrow with PII encrypted via existingencryptPatientPHIhelpers; statusnew;formType='booking'. - Side-effects via
executionCtx.waitUntil(), non-blocking:- Patient auto-reply email (if
emailprovided andautoReplyEnabled) — existing pipeline, sender voice “[Clinic Name]”. - In-app notification + browser push to clinic users with
website_leads:viewpermission. - Per-user “email me on web booking” emails (per Q7 notification matrix).
- Audit log:
lead.booking.submitted,appointment.requested.created(if auto-confirmed).
- Patient auto-reply email (if
- Response 200:
| Code | HTTP | Meaning |
|---|---|---|
INVALID_TOKEN | 403 | Token unknown/revoked/inactive |
ORIGIN_NOT_ALLOWED | 403 | Origin not in allowedOrigins |
INVALID_TURNSTILE | 403 | Turnstile siteverify failed |
CLINIC_UNAVAILABLE | 503 | Tenant trial expired / suspended |
INVALID_PAYLOAD | 400 | Body validation failure (per-field details) |
SLOT_TAKEN | 409 | Requested slot no longer available; response includes fresh slots |
DUPLICATE_PENDING | 422 | Phone already has a pending booking at this clinic |
RATE_LIMITED | 429 | IP, form, or clinic cap exceeded |
INTERNAL_ERROR | 500 | Anything else |
| Scope | Limit | Notes |
|---|---|---|
| Slot queries / IP | 20/min | CF binding |
| Slot queries / form | 300/min | CF binding |
| Submissions / IP | 5/hour | CF binding |
| Submissions / clinic | dailySubmissionCap (default 50/day) | DB count WHERE created_at > now() - '24h' |
6. Embed widget
6.1 Hosting
New Cloudflare Pages projectbooking-embed-app/ (separate from marketplace-app/). Deployed at embed.odontox.io. Bundle path: embed.odontox.io/v1/booking.js (versioned path so we can ship v2 without breaking v1 embeds).
6.2 Clinic embed snippet
data-token. data-target defaults to #odontox-booking. data-lang defaults to en.
6.3 Rendering
- Shadow DOM (open mode for inspectability). CSS isolation from host; layout still flows in the host.
- Mobile-first responsive. Single-column form on narrow viewports.
- Inherits host page font stack so it looks native; uses
bookingConfig.primaryColorfor accent (submit button, focused inputs). - Two states:
- Form state — fields in order: First name, Last name, Phone, Email (if
emailfield shown), DOB (ifrequireDob), Treatment dropdown (ifshowTreatmentDropdown), Notes, Date picker, Time picker, Submit. - Success state — “Thanks , has received your booking request for at . You’ll get a confirmation email shortly.” + “Book another” button.
- Form state — fields in order: First name, Last name, Phone, Email (if
6.4 Behavior
- On load:
GET /config→ render form → mount Turnstile invisible. - Date picker is calendar-style. Greys out: closed days, fully-booked days, days outside
[now + minHoursAhead, today + maxDaysAhead]. Lazy-fetches/available-slotswhen the picker opens; pre-fetches today + next 7 days in background. - Time picker shows actual openings for the selected date. Empty → “No slots available, try another day.”
- Submit:
- Client generates
Idempotency-KeyUUID in memory. - Show spinner; disable form.
- On
409 SLOT_TAKEN: toast “That slot was just taken, please pick another”; re-render time picker with the fresh slots from the response. - On
422 DUPLICATE_PENDING: toast “You already have a pending request. We’ll be in touch.” - On
429: toast “Too many requests. Please try again in a few minutes.” - On
503: toast “Online booking is temporarily unavailable. Please call the clinic.” - On
200: render Success state.
- Client generates
- All copy keyed by
lang.env1;uris a follow-up.
6.5 “Powered by” footer
Bottom-right, ~16px tall OdontoX logo SVG only (no “OdontoX” text).<a href="https://odontox.io" target="_blank" rel="noopener"> wrapping the SVG. Non-removable in v1.
6.6 Security in the widget
- No secrets in the bundle. Token is by design semi-public; origin allowlist + Turnstile + rate limits are the defense.
- CSP-friendly: no inline scripts, no
eval. Strict CSP nonce hooks supported via data-attr if a clinic wants it (data-csp-nonce="..."). - All API calls hit
https://api.odontox.io/api/v1/public/booking/*(same origin family as the rest of the platform).
7. In-app surfaces
7.1 Settings → Website Forms (extending existing leads admin)
Extend the existing leads form configuration page indashboard-app/.
- Existing “Contact Form” config UI unchanged.
- Add ”+ Add Booking Form” button → wizard creating a
lead_form_configsrow withformType='booking'. Sections:- Basic — label, allowed origins (multi-input), active toggle.
- Form fields — Require DOB, Show Treatment Dropdown, Treatment Options editor.
- Branding — Primary color picker, Intro text.
- Slot rules — Max days ahead (1..90, default 30), Min hours ahead (0..72, default 2), Auto-confirm returning patients (default off), Daily submission cap (10..500, default 50).
- Auto-reply email — existing toggle + subject + body editor.
- Embed snippet — copy-paste box with the
<script>, plus “Test in new tab” button that opens the widget against this token in isolation against a host-page sandbox we ship atembed.odontox.io/v1/test. - Danger zone — Rotate token (immediate invalidation), Delete form.
- Inline stats per form: last submission timestamp, 7-day submission count, token preview (masked).
7.2 Website Leads page (extending existing)
- Adds a “Form type” filter chip: All / Contact / Booking.
- Booking rows render extra columns: Requested date, Requested time, Treatment, “Returning” badge (when
phoneMatchPatientIdis set). - Clicking a booking row opens the existing lead detail drawer with a new “Booking” section showing the slot/treatment/notes; primary actions become Accept / Decline for booking-type leads (contact-type leads keep “Convert to Patient”).
7.3 Appointments → Requests tab (new tab on existing Appointments page)
- Tab order: Calendar | List | Requests (N) — N is a badge with the pending count, refreshed via TanStack Query on focus.
- Query:
- Columns: Time received, Patient name (+ “Returning” badge if
phoneMatchPatientId), Phone, Requested date/time, Treatment, Notes (truncated, hover full), Source URL (small subtle), Actions. - Inline buttons: Accept, Decline.
- Returning patient (
phoneMatchPatientIdset):- One click. Server: create
appointmentsrow (statusrequested) against that patient ifappointmentIdnot already populated; update leadstatus='converted', setappointmentId. Existing notification pipeline fires (in-app + WhatsApp + email per status notification matrix). - UI: side-drawer opens with the new appointment for review.
- One click. Server: create
- New person (no
phoneMatchPatientId):- Modal opens pre-filled from lead form data (First/Last/Phone/Email/DOB).
- Reception edits if needed → “Confirm” → server txn: create
patientsrow (MRN auto-assigned viadocument-numbering; no portal user, no PIN, no invite email) + createappointmentsrow (requested) + update lead (status='converted',convertedPatientId,appointmentId). - UI: side-drawer opens with the new appointment.
- Modal with reason dropdown: “Slot no longer available” / “Spam” / “Duplicate” / “Other” + optional text.
- Optional checkbox “Send polite email to patient if email provided” — default ON for non-spam reasons, force-OFF (and disabled) for Spam.
- Submit → lead
status='archived'(or'spam'if reason is Spam).
7.4 Dashboard widget
Small card on the main dashboard. Shows “Pending booking requests: N” (current count, gated bywebsite_leads:view). Clicks through to Appointments → Requests tab. ~30min of work; included because of explicit “full-on environment inbox” feedback.
7.5 Notifications
- New event type:
booking_request_received. Triggers:- In-app notification (existing
createNotification) to all users at the clinic withwebsite_leads:view. - Browser push (existing PushNotification path) to the same set.
- Optional per-user email (per-user opt-in in notification preferences: “Email me when a web booking comes in”).
- In-app notification (existing
- Patient-facing email is the auto-reply that fires at submit (existing pipeline).
- WhatsApp/email to the patient at the appointment level (existing pipeline) fires when reception Accepts, gated by the existing WhatsApp module/feature flags.
7.6 Permissions
website_leads:view(existing) — gates Website Leads page + Requests tab + dashboard card.website_leads:manage(existing) — gates Accept/Decline buttons.bookings:forms:configure(new) — gates Settings → Website Forms booking-form editor. Defaults to admin only.
- Admin: view + manage + forms:configure.
- Reception: view + manage. Default ON (explicit per user request).
- Doctor: view only. Default ON, toggleable per clinic.
- Patient: no access.
8. Security, observability, edge cases
8.1 Layered defense
| Layer | Purpose |
|---|---|
X-OdontoX-Token header | Identifies form; rotatable; revocable per form |
| Origin allowlist (per form) | CORS only echoes allowed origins; non-browser callers blocked |
| Cloudflare Turnstile (invisible) | Bot defense; siteverify on every submit |
| Honeypot field | Cheap second-line bot defense; fake 202 response |
| IP rate limits | 20 slot/min, 5 submits/hour per IP (CF binding) |
| Per-form daily cap | Default 50/day; configurable in form settings |
| Phone uniqueness | Max 1 pending booking per (clinic, normalized phone) |
| Dedupe hash (existing) | 5-min upsert window for accidental double-clicks |
| PII encryption | Reuses encryptPatientPHI helpers for First/Last/Email/Phone/DOB/Notes |
| Idempotency-Key | Client UUID, 24h replay window |
| Slot re-check at submit | Race-condition guard with 409 + fresh slots |
| Audit log | Every submission, accept, decline, token rotation |
| IP hashing | sha256(ip + clinicId), purged after 90 days (existing pattern) |
| No PHI in logs | Worker logs structured with formId, clinicId, eventType only |
8.2 Observability
- Worker logs: structured, no PII, includes
formId,clinicId,eventType, status code, response time. - Counters (CF analytics / D1):
submissions_totalper clinic per dayslot_queries_totalper clinic per daysubmissions_409_rate(signals slot scarcity → ops can suggest more doctors)submissions_429_rate(signals abuse or legit spike)turnstile_failure_rate(signals widget integration breakage)
- Superadmin dashboard: new “Web Booking activity” card — top clinics by submissions this week, error rates, key/form health.
- No new Langfuse traces (mechanical, not AI).
8.3 Edge cases
- Clinic closes mid-day →
available-slotsrecomputes next request; widget re-fetches on next date pick. - Slot taken between picker render and submit → 409 with fresh slots, widget re-prompts.
- Patient with no email → on-screen confirmation only, no auto-reply.
- Phone normalization fails → 400
INVALID_PAYLOADbefore insert. - Multiple booking forms on same site → fine, different tokens.
- Token rotated mid-session → 403 on submit; widget shows “Please refresh the page” toast.
- Phone matches a soft-deleted patient → treated as no-match; falls into Pending inbox.
- Clinic on expired trial / suspended →
/configreturns 503CLINIC_UNAVAILABLE; widget shows tasteful “Online booking is temporarily unavailable” message (per trial-gating memory). - Per-clinic license patient cap reached at Accept time → server returns clear error to reception (“Patient limit reached, upgrade to add new patients”); same guard as existing patient creation.
- Returning patient with
autoConfirmIfPhoneMatch=trueAND slot taken between submit and insert → 409; widget re-prompts even though phone matched.
9. Rollout
9.1 Phase 1 (this spec)
- Schema migrations.
- Public API endpoints.
- Cloudflare Pages project
booking-embed-app/+ initial v1 bundle. - Settings → Website Forms booking-form editor.
- Website Leads page extension (form-type filter, booking columns, Accept/Decline actions).
- Appointments → Requests tab.
- Dashboard “Pending booking requests” card.
- Permission tree update (reception default ON).
- Tenant feature flag:
clinic_modules.config.tabs.webBookingEnabled(default OFF per WA lifecycle pattern). Clinics opt in. - Internal docs (
docs/api-reference.mdper API documentation discipline memory). - Superadmin parity (per superadmin UI parity memory): superadmin can list/inspect forms + submissions across all tenants, override flag per tenant.
9.2 Phase 2 (post-launch, NOT in this spec)
- Per-doctor booking forms (doctor selector in widget).
- Urdu (
ur) language pack. .icscalendar attachment in auto-reply email.- SMS auto-reply option.
- Optional phone-OTP verification toggle per form.
- Patient self-cancel via signed manage-booking link in auto-reply email (reuses existing
/public-appointments/respondflow).
10. Out of scope (explicit)
- Online payment / deposits.
- Real-time slot push (SSE/WebSocket).
- Multi-clinic single-form booking.
- Native mobile app integration.
- Marketing/SEO booking pages (
book.odontox.io/<slug>was considered as a fallback in brainstorming; not in v1).
11. Open follow-ups for the implementation plan
- Confirm exact migration file numbering (next after most recent migration).
- Confirm whether
appointments.appointmentDate/appointmentTimetyping matches what the slot picker produces (YYYY-MM-DD+HH:mm). - Confirm existing
calculateAvailableSlotsacceptsdoctorId: nullfor “any doctor” or whether a small adaptation is needed. - Confirm the existing Cloudflare rate-limit binding has a separate namespace for booking-public endpoints.
- Confirm whether the existing leads form admin page lives in
dashboard-app/and not a separate route bundle. - Identify the exact superadmin route to extend for the inspection view.
Approved by user: Yes (2026-05-24 brainstorming session). Ready for implementation plan: Yes.

