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 (
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
}

View File

@ -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 {