Public booking (appointments)

Book an appointment (guest checkout)

post/api/book/{slug}/appointments

Public guest checkout. The requested slot is re-derived server-side, not in the past, inside the provider's working hours, not inside a blocked period; because the wizard only offers valid slots but nothing stops a direct POST inventing one. Overlap is enforced by the database, not here.

The client record is upserted on (company_id, email), so a returning customer accumulates history on one row. notes goes on the appointment, never on the client record.

Engine-boundary note. This route checks only that the org is active; it does NOT check business_type. Its hospitality counterparts go through hospitalityCompany(), which refuses an appointments org outright. So the public surface enforces the engine boundary in one direction only: a hospitality org that has any active book_services row is publicly bookable here. See the "Two engines" section.

Path Parameters

slug*string

The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

curl -X POST "https://example.com/api/book/string/appointments" \  -H "Content-Type: application/json" \  -d '{    "providerId": "4834bcdc-4a64-444d-966b-1a6fe381da24",    "startsAt": "2019-08-24T14:15:22Z",    "customer": {      "name": "string",      "email": "user@example.com",      "phone": "string"    }  }'
{  "appointment": {    "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",    "starts_at": "2019-08-24T14:15:22Z",    "ends_at": "2019-08-24T14:15:22Z"  },  "calendar": {    "googleCalendarUrl": "http://example.com",    "icsUrl": "http://example.com"  },  "promotion": {    "kind": "sale",    "discountCents": 0  }}

List bookable appointment slots GET

The public availability grid. Reads through the service-role client inside this route rather than through an anon RLS policy, so there is one security model to reason about rather than two. Slot allocation is first-come: for a given start time the first provider found free wins it. There is no fairness or least-booked balancing. Slots whose start time has already passed are filtered out, because the booking POST rejects them anyway. **Ranged mode (`days`).** Answers up to 14 consecutive days from `date` in one request, for the wizard's landing walk, which used to fire one request per day (up to 14) hunting for the first open one. The single-date shape (`{slots}`) is unchanged and stays the default; sending `days` greater than 1 switches the response to `{days: {"<date>": {slots}, ...}}`, one entry per requested date.

Preview a promo code before checkout (public, unauthenticated) POST

Never creates or charges anything: a preview so the widget can say "that code works, $12 off" before the guest fills in their details. POST /api/book/{slug}/appointments (the real checkout) independently re-resolves the SAME code via the same resolveBestPromotion() (src/lib/booking/promotions-server.ts) and is the only answer ever actually charged; a "valid" preview here can still be refused at checkout if, for example, the guest turns out to have already used a once-per-customer code (this route has no customer id yet to check that against). Rate-limited on the tighter 'write' guest bucket (30/min): a code is a short, guessable string, and this is the one endpoint that lets someone probe a guess without booking anything.