Shared

Live booking-URL availability while typing, during onboarding

post/api/onboarding/slug-check

A DB read, not a metered third-party call like places/details next to it: the ceiling here is about typing speed and abuse, not a bill. Lets OnboardingWizard's confirm step show "Available" or "Already taken" as the operator types, instead of only finding out from the 23505 catch on submit (onboarding/complete still has that catch; this route is UX only and changes no enforcement).

validSlug() runs first and for free: an invalid format or a reserved word (api, book, dashboard, ...) never reaches the database, and is a real, displayable answer rather than a 400, since an operator mid-keystroke on a name passes through several too-short prefixes on the way to a valid one.

Authorization

sessionCookie
sb-qxrvgfkjyvbngipqvslu-auth-token<token>

The dashboard's Supabase Auth session cookie, set at sign-in. Large sessions are split across numbered chunks (…auth-token.0, .1), so treat this as a cookie family rather than one name.

Every request re-validates it against the Auth server (getUser()), never by decoding the cookie locally: a JWT nothing has checked is not a credential. Tenancy is then read from the verified app_metadata.company_id claim and enforced by row-level security; it is never read from request input, on any route, ever.

role (admin / staff) is deliberately not in RLS. It gates specific actions in route code, the operations marked admin below, so hiding a button in the UI is cosmetic only, and a route's own check is the enforcement.

In: cookie

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

curl -X POST "https://example.com/api/onboarding/slug-check" \  -H "Content-Type: application/json" \  -d '{    "slug": "string"  }'
{  "available": true,  "reason": "taken"}

Create a business: a new organization, or another one in the caller's own (onboarding) POST

Where a business is born, now that a tenant is two rows deep (migration 0031). **The only creation path**: `POST /api/companies` was a second one and was deleted on 2026-07-29, having never had a caller and having created companies with no organization above them. **Two modes, decided by the caller's `company_id` claim, over one identical write set.** *No claim*: a brand new tenant. Creates `book_organizations` -> `book_companies` (with the structured address) -> `book_org_members` (admin) -> `book_organization_members` (owner) -> the `company_id` claim. `organizationName` is required. *A claim, and the caller OWNS the organization their active business belongs to*: adds a business to that organization. Same writes minus the two that belong to an organization's own creation: no `book_organizations` row, and no second `book_organization_members` row (it exists, and `unique (organization_id, user_id)` would reject it). `organizationName` is ignored: the organization is resolved from the caller's verified ownership, never from the body. *A claim, and the caller does NOT own it*: 409, exactly as before. A plain admin or staff member cannot create a business. The target organization is the one the caller's ACTIVE business belongs to, not "any organization this user owns". Those coincide today; the active-business rule is the one that stays unambiguous if they ever stop coinciding, and it matches what the operator is looking at. `requireMember()` is deliberately not used, for the reason `POST /api/organization/switch-business` sets out: it answers whether the caller is a member of the business they are currently IN, which is not the permission being asked for. The `book_organization_members` owner row is the authorization, read with service-role and pinned to the caller's own verified `user.id`. Service-role throughout, because every write either happens BEFORE the caller's JWT carries a claim (new tenant) or against a DIFFERENT company than the one it names (added business). RLS would reject both. Each step rolls back everything before it, but only the organization it created, never one that was already there. **The claim moves to the new business in both modes**, which in the second is a switch: the operator has just created a venue with no services, staff or tables, and every one of those screens is scoped by the claim. **Hours and periods are best-effort and are NOT rolled back.** A business with no hours is an ordinary supported state that both screens render; discarding a successfully created one over a schedule insert would trade a small self-correcting gap for the loss of all the operator's work. The atomic replace RPCs are structurally unusable here; both read `company_id` off the caller's JWT, which at that instant names no company or the previous one, so they would write the schedule onto the wrong venue.

Look up a Google Business Profile for onboarding prefill POST

Server-side proxy for Google Places Details (New). Exists so the credential with Details access never reaches a browser: the client-side Autocomplete widget holds a separate, referrer-restricted key that can only produce predictions. Google's response shape is mapped to this app's own shapes here, so no Google type reaches the client; `addressComponents` become country/state/city/postalCode, `regularOpeningHours.periods` become the same DayWindow array `WeekHoursEditor` already edits. **Always 200, even when nothing is found.** The wizard treats "no data" as "stay in manual entry", never as an error, so a miss and a Google-side failure collapse to the same `{found:false}` rather than becoming a dead end mid-onboarding. Field selection here is a BILLING decision, not a performance one: Google charges Place Details at the tier of the MOST EXPENSIVE field requested. `regularOpeningHours` and `businessStatus` are Enterprise-tier and opening hours are the point of the feature, so this call is already billed at Enterprise, which is what makes `nationalPhoneNumber` (also Enterprise) and `primaryType` (Pro, below it) free at the margin. Check the per-field tier table in Google's Place Details docs before adding to the mask; one field a tier up raises the price of every lookup. The phone number used to be excluded here on the grounds that step 3 showed no phone field, and nothing should be written that the operator cannot see and correct. That reasoning stands; the premise changed: step 3 now shows address and phone, so both are prefilled and both are correctable. `websiteUri` (Pro tier, also free at the margin) was added 2026-08-19; still not extracted: rating, reviews, photos or editorialSummary/description, all Enterprise+Atmosphere, a tier above what this call already pays. Reviews specifically are strictly worse here than the OAuth Google Business Profile connection (docs/google-business-profile.md) already gets: up to 5 Google-picked reviews with no reply capability, versus that connection's full history and reply-to-review. Not a fit for a call that fires on every signup regardless of whether the business ever uses reviews.