Public booking (appointments)

Preview a promo code before checkout (public, unauthenticated)

post/api/book/{slug}/promo-codes/validate

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.

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

curl -X POST "https://example.com/api/book/string/promo-codes/validate" \  -H "Content-Type: application/json" \  -d '{    "code": "string",    "serviceIds": [      "a486dd3f-c8ab-4226-b78c-e435f821cb91"    ]  }'
{  "discountCents": 0}

Book an appointment (guest checkout) POST

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.

Send a big-group enquiry (public) POST

What a guest sends when their party is over the `maxOnlinePartySize` a venue has set (migration 0036). Creates a `book_group_enquiries` row and opens a thread on it, which lands in the dashboard inbox beside its booking conversations. This is precisely the public unauthenticated write surface migration 0024 declined to build for intake forms; "a standalone shareable form URL would be a new unauthenticated write surface needing its own rate limiting and spam story". It inherits that whole obligation: `guestRateLimit` on the `write` budget runs FIRST, before the slug is even resolved, so this cannot be used to probe which slugs exist either. **A party at or below the ceiling is refused**, and a venue that has set no ceiling at all refuses everything here. Without that check this endpoint would be an open "email the business" box on every hospitality org.