Start the Google Business Profile OAuth flow (admin, redirect-only)
/api/google-business/connectFull 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.
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
Response Body
curl -X GET "https://example.com/api/google-business/connect"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.
Google OAuth callback: exchange the code, resolve the location, store the connection GET
Where Google sends the browser back to after the owner grants or denies consent: same browser, same session as `/connect`, which is why this reuses `requireMember('admin')` rather than a separate scheme. Validates `state` against the `gbp_oauth_state` cookie `/connect` set; a mismatch (or missing cookie) is `gbp_error=state_mismatch` rather than proceeding. Exchanges `code` for tokens, requires a refresh token to be present (a fresh grant with `prompt=consent` should always include one; a response without one is refused rather than silently stored access-token-only, which would look connected and stop working the moment that token expires). **v1 assumes one Google Business Profile location per connection**, matching this app's own one-`book_companies`-row-per-physical-location model. Takes the caller's first Google account, then within it the location whose `metadata.placeId` matches this company's own `google_place_id` (0031, the same id already captured for free during onboarding's Places search), falling back to that account's first location when there is no match or the company never ran that search. A business with several GBP locations connecting the wrong one is a documented v1 limitation (docs/google-business-profile.md), not a silent bug. Stores the connection via `book_google_business_connections` (0090): a refresh token AES-256-GCM encrypted at rest, an access token cached alongside it. The Google account's email is read from the signed-in admin's own session, not from Google's token response (which doesn't include it), since a fifth external call for a label shown for reassurance only was not worth it.