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-Lengthrequired (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.
GETrenders 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.
instanceIdis 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