fix: switch location lookup to /oauth/installedLocations (oauth.readonly scope)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

/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 <noreply@paperclip.ing>
This commit is contained in:
Head of Product & Engineering 2026-04-06 10:57:16 +02:00
parent 3863e8f0cd
commit 59b0a8c93f
2 changed files with 16 additions and 12 deletions

View File

@ -17,10 +17,10 @@ import (
) )
const ( const (
ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token" ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token"
ghlLocationTokenURL = "https://services.leadconnectorhq.com/oauth/locationToken" ghlLocationTokenURL = "https://services.leadconnectorhq.com/oauth/locationToken"
ghlLocationsURL = "https://services.leadconnectorhq.com/locations/search" ghlInstalledLocationsURL = "https://services.leadconnectorhq.com/oauth/installedLocations"
ghlLocationAPIVersion = "2021-07-28" ghlLocationAPIVersion = "2021-07-28"
) )
// TokenStore is the interface OAuthHandler uses for token persistence. // 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 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) { 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 { if err != nil {
return nil, err return nil, err
} }
q := req.URL.Query() q := req.URL.Query()
q.Set("companyId", companyID) q.Set("companyId", companyID)
q.Set("isInstalled", "true")
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
req.Header.Set("Authorization", "Bearer "+companyAccessToken) req.Header.Set("Authorization", "Bearer "+companyAccessToken)
req.Header.Set("Version", ghlLocationAPIVersion) req.Header.Set("Version", ghlLocationAPIVersion)
@ -308,14 +310,14 @@ func (h *OAuthHandler) getCompanyLocations(ctx context.Context, companyAccessTok
return nil, err return nil, err
} }
if resp.StatusCode != http.StatusOK { 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 { 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 return locResp.Locations, nil
} }

View File

@ -11,13 +11,15 @@ type TokenResponse struct {
InstalledLocations []string `json:"installedLocations"` // populated on bulk company installs InstalledLocations []string `json:"installedLocations"` // populated on bulk company installs
} }
// LocationInfo represents a GHL location entry from the /oauth/installedLocations response.
type LocationInfo struct { type LocationInfo struct {
ID string `json:"id"` ID string `json:"_id"` // /oauth/installedLocations uses _id
Name string `json:"name"` Name string `json:"name"`
} }
type LocationsResponse struct { type InstalledLocationsResponse struct {
Locations []LocationInfo `json:"locations"` Locations []LocationInfo `json:"locations"`
Count int `json:"count"`
} }
type OutboundMessageWebhook struct { type OutboundMessageWebhook struct {