Look up a Google Business Profile for onboarding prefill
/api/places/detailsServer-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.
Authorization
sessionCookie 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
application/json
curl -X POST "https://example.com/api/places/details" \ -H "Content-Type: application/json" \ -d '{ "placeId": "string" }'{ "found": true, "name": "string", "address": "string", "country": "string", "state": "string", "city": "string", "postalCode": "string", "lat": 0, "lng": 0, "businessStatus": "string", "phone": "string", "primaryType": "string", "website": "string", "hours": [ { "dayOfWeek": 0, "startTime": "string", "endTime": "string" } ]}Live booking-URL availability while typing, during onboarding POST
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.
Start the Google Business Profile OAuth flow (admin, redirect-only) GET
Full top-level navigation, not a fetch: clicking "Connect" in the Reviews module lands here, and this redirects straight to Google's own consent screen for the `business.manage` scope. `access_type=offline` + `prompt=consent` force a refresh token on every grant. Blocked until Google approves this app's own Business Profile API access AND verifies the OAuth consent screen for that scope (see docs/google-business-profile.md); `not_configured` below is what a deployment without both looks like. Sets a short-lived, httpOnly `gbp_oauth_state` cookie carrying a random nonce, echoed back to `/api/google-business/callback` as `state`. This is the CSRF defence for this flow: without it, an attacker who starts their OWN consent grant could trick a victim admin into completing it, linking the attacker's Google account to the victim's company. Every failure redirects back to `/reviews?tab=google&gbp_error=<code>` rather than returning JSON: there is no client here to react to a JSON body, only a browser mid-navigation.