From 59b0a8c93fe5c2b25ffc933dc4ef732a0c5bcd3a Mon Sep 17 00:00:00 2001 From: Head of Product & Engineering Date: Mon, 6 Apr 2026 10:57:16 +0200 Subject: [PATCH] fix: switch location lookup to /oauth/installedLocations (oauth.readonly scope) /locations/search requires locations.readonly which GHL never includes in company-level OAuth tokens. /oauth/installedLocations uses oauth.readonly, which is always present in company tokens, and returns only locations where this app is actually installed. Co-Authored-By: Paperclip --- internal/ghl/oauth.go | 22 ++++++++++++---------- internal/ghl/types.go | 6 ++++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/ghl/oauth.go b/internal/ghl/oauth.go index 45d9ea2..3af9d2d 100644 --- a/internal/ghl/oauth.go +++ b/internal/ghl/oauth.go @@ -17,10 +17,10 @@ import ( ) const ( - ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token" - ghlLocationTokenURL = "https://services.leadconnectorhq.com/oauth/locationToken" - ghlLocationsURL = "https://services.leadconnectorhq.com/locations/search" - ghlLocationAPIVersion = "2021-07-28" + ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token" + ghlLocationTokenURL = "https://services.leadconnectorhq.com/oauth/locationToken" + ghlInstalledLocationsURL = "https://services.leadconnectorhq.com/oauth/installedLocations" + ghlLocationAPIVersion = "2021-07-28" ) // TokenStore is the interface OAuthHandler uses for token persistence. @@ -286,14 +286,16 @@ func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *To return installed, nil } -// getCompanyLocations lists all locations for a company using the company-scoped token. +// getCompanyLocations lists installed locations for a company using /oauth/installedLocations. +// This endpoint requires the oauth.readonly scope, which is present on all Company-level tokens. func (h *OAuthHandler) getCompanyLocations(ctx context.Context, companyAccessToken, companyID string) ([]LocationInfo, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ghlLocationsURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ghlInstalledLocationsURL, nil) if err != nil { return nil, err } q := req.URL.Query() q.Set("companyId", companyID) + q.Set("isInstalled", "true") req.URL.RawQuery = q.Encode() req.Header.Set("Authorization", "Bearer "+companyAccessToken) req.Header.Set("Version", ghlLocationAPIVersion) @@ -308,14 +310,14 @@ func (h *OAuthHandler) getCompanyLocations(ctx context.Context, companyAccessTok return nil, err } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("locations endpoint returned %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("installedLocations endpoint returned %d: %s", resp.StatusCode, string(body)) } - var locResp LocationsResponse + var locResp InstalledLocationsResponse if err := json.Unmarshal(body, &locResp); err != nil { - return nil, fmt.Errorf("failed to parse locations response: %w", err) + return nil, fmt.Errorf("failed to parse installedLocations response: %w", err) } - slog.Info("ghl company locations fetched", "company_id", companyID, "count", len(locResp.Locations)) + slog.Info("ghl installed locations fetched", "company_id", companyID, "count", len(locResp.Locations)) return locResp.Locations, nil } diff --git a/internal/ghl/types.go b/internal/ghl/types.go index 539161b..7e64a76 100644 --- a/internal/ghl/types.go +++ b/internal/ghl/types.go @@ -11,13 +11,15 @@ type TokenResponse struct { InstalledLocations []string `json:"installedLocations"` // populated on bulk company installs } +// LocationInfo represents a GHL location entry from the /oauth/installedLocations response. type LocationInfo struct { - ID string `json:"id"` + ID string `json:"_id"` // /oauth/installedLocations uses _id Name string `json:"name"` } -type LocationsResponse struct { +type InstalledLocationsResponse struct { Locations []LocationInfo `json:"locations"` + Count int `json:"count"` } type OutboundMessageWebhook struct {