Project One

Native Bookings

The self-owned booking system (docs/10): open-slot computation, booking with race-safe slot claims, the lead's HMAC manage link for reschedule/cancel, and host-marked outcomes that drive recovery nurture. Slot GET and booking POST carry permissive CORS because published client pages may live on other origins.

Endpoint Auth Rate limit Body cap
GET /api/bookings?instanceId= public 60/min per IP + 40/min per instance —
POST /api/bookings public 10/min per IP + 15/min per instance 1 MB
OPTIONS /api/bookings public none —
GET /api/bookings/manage?token= HMAC capability URL 30/min per IP (shared key with POST) —
POST /api/bookings/manage HMAC token (form field) 15/min per IP (shared key with GET) —
POST /api/bookings/mark session cookie (live) 30/min per IP 1 MB

The per-instance ceilings (WSTG-ATHZ-04) sit above the per-IP keys: they blunt distributed enumeration or booking floods aimed at a single tenant from many IPs.


GET /api/bookings?instanceId=<id>

Open UTC slot set for an instance — the published page's calendar block fetches this.

Responses

Status Body
200 (live) { "ok": true, "slots": ["<UTC ISO>", ...], "durationMin": n, "timezone": "<IANA>" }
200 (live, unknown or non-active instance) { "ok": true, "slots": [], "durationMin": n, "timezone": "..." } — an honest empty set (still cached). Unknown instances and paused/provisioning/offboarded tenants are indistinguishable from "no availability"; never a 404, never demo data (WSTG-ATHZ-04)
200 (demo) same shape + "mode": "demo" (Mon-Fri 9-5 Australia/Sydney, 30-min slots, 4-day window)
400 { "ok": false, "missing": ["instanceId"] }
429 shared envelope
500 (live read failed) { "ok": false, "error": "Couldn’t load times right now — try again shortly." } — honest failure, never fabricated demo slots; uncacheable

Caching: live Cache-Control: public, s-maxage=60, stale-while-revalidate=300; demo s-maxage=300, swr=600. CDN-only (s-maxage) — browsers revalidate. Slots may therefore be up to a minute stale; the booking POST re-validates, so treat a 409 as normal flow.

CORS: Access-Control-Allow-Origin: * on 200/400 — not on 429, so a rate-limited cross-origin caller sees an opaque error.


POST /api/bookings

Book a slot. Validates against the freshly computed slot set (never the CDN copy) and claims it race-safely.

Request

JSON or form-encoded; strings trimmed, capped at 500 chars.

Field Required Notes
instanceId (alias instance_id) yes
slot yes any Date.parse-able form of the instant; canonicalized to toISOString() and must be an exact member of the current slot set
email yes lowercased, regex-validated
name, phone no

Responses

Status Body
200 (live) { "ok": true, "message": "You're booked. …", "scheduledAt": "<UTC ISO>" }
409 { "ok": false, "reason": "slot_unavailable", "slots": [...] } — a fresh slot set to re-offer; use it instead of re-fetching the (possibly CDN-stale) GET
409 (non-active funnel) same slot_unavailable reason but with an empty slots: [] — booking into an unknown/paused funnel is refused before any write (WSTG-ATHZ-04). Distinguish empty vs populated: populated = re-offer, empty = this funnel isn't taking bookings
200 (demo) booking-shaped ack + "mode": "demo" — nothing stored
400 { "ok": false, "missing": [...] } (instanceId, slot, email)
411 / 413 / 429 shared envelopes
500 (live write failed) { "ok": false, "error": "That didn’t go through — your spot wasn’t saved. Please pick a time again." } — honest failure; never a fabricated "you're booked" demo ack

Side effects (live)

Find-or-create contact by (instance, email) → insert bookings row (booked) → contact stage booked → stop on_lead nurture, enroll on_booked anchored to the call time → owner email with ICS invite (SEQUENCE 0, UID = bookingId) → immediate nurture tick so the lead's confirmation (with its own ICS + manage link) sends at booking time → telemetry call_booked (features only). Email/telemetry failures never lose the booking.

Notes for callers

  • Not idempotent: retrying a success returns 409 (the slot is now yours/taken). Concurrent racers are settled by a unique DB index; the loser gets the 409 + fresh slots.
  • Live slot conflicts do not fall back to demo — 409 comes through as-is.
  • Content-Length required (411 otherwise).

GET / POST /api/bookings/manage

The lead's self-service reschedule/cancel page, reached from the confirmation email's capability URL.

Token: base64url(bookingId:instanceId:sig), sig = HMAC-SHA256("book:" + bookingId + ":" + instanceId, UNSUBSCRIBE_TOKEN_SECRET) — the book: domain prefix separates it from the unsubscribe token family. Stateless, reusable, non-expiring; binds exactly one booking.

This is an HTML surface, not a JSON API. Every non-429 outcome is HTTP 200 text/html; outcomes differ only in page content ("Manage your call" / "That time was just taken" / "Cancelled" / "Moved" / "That link didn't work" / demo notes). Programmatic detection requires parsing the page title.

  • GET renders the page: current time (visitor-localized by inline JS), up to 24 reschedule buttons (self-posting forms), a cancel form.
  • POST (form-encoded only): token, action = cancel | reschedule, slot (reschedule; same membership rules as booking).

Side effects (live): cancel → status canceled, on_booked nurture stopped, owner email (no ICS). Reschedule → status rescheduled, scheduled_at moved, nurture re-anchored, owner email with ICS SEQUENCE 1 so calendars replace the invite.

Notes: GET (30/min) and POST (15/min) share one rate-limit key — a burst of page loads consumes the form-post budget. Cancel is idempotent.


POST /api/bookings/mark

Host-marked outcome from the member console. showed feeds diagnostics; no_show triggers the recovery sequence.

Request (JSON only)

{ "bookingId": "<uuid>", "outcome": "showed" | "no_show" }

Responses

Status Body
200 (live) { "ok": true, "outcome": "...", "message": "Marked as showed — …" | "Marked as a no-show — …" }
404 { "ok": false, "error": "Booking not found." } — covers both "doesn't exist" and "not yours" (RLS, anti-enumeration)
400 { "ok": false, "error": "Send { bookingId, outcome: \"showed\" | \"no_show\" }." }
500 honest failure when configured — never masked as demo success (this gates recovery flows)
200 (demo) ack + "mode": "demo", no auth required keyless

Notes for callers

  • Idempotent: re-posting the same outcome skips the nurture side effects and returns the identical body.
  • instanceId is never a request field — it's derived from the session server-side.
  • Unauthenticated live callers currently surface as 500 (the context throw is caught generically), not 401.

Source: app/api/bookings/route.ts, app/api/bookings/manage/route.ts, app/api/bookings/mark/route.ts, src/lib/comms/bookings.ts, src/lib/comms/schedule.ts, src/lib/comms/booking-events.ts, src/lib/comms/manage-token.ts