In short
- Register your app in the GHL developer portal to get a client_id and client_secret.
- Redirect users to https://marketplace.gohighlevel.com/oauth/chooselocation to start authorization.
- Exchange the returned code for access and refresh tokens at the token endpoint.
- Store tokens per user and refresh them before they expire. Never reuse tokens across sub-accounts.
Endpoints
| Method | Path | Scopes | Rate limit |
|---|---|---|---|
| GET | https://marketplace.gohighlevel.com/oauth/chooselocation (browser redirect) | None (start of OAuth flow) | n/a |
| POST | https://services.leadconnectorhq.com/oauth/token | None (token exchange) | See GHL API docs |
| GET | https://services.leadconnectorhq.com/oauth/installedLocations | locations.readonly | See GHL API docs |
Authentication
You need a GHL developer account to create a marketplace app. Register at marketplace.gohighlevel.com/developer.
Your app has two token types: an agency-level access token (if the user installs on an agency) and sub-account (location) access tokens. Most API calls require a location-level token. Use GET /oauth/installedLocations to get the list of installed sub-accounts, then exchange for a location token.
Access tokens expire (the exact lifetime is in the token response). Refresh tokens are long-lived. Store both in your database, indexed by the userId and locationId.
The client_secret must never be exposed in client-side code or public repos. Keep it in your server environment variables only.
Always verify the exact OAuth endpoint URLs and token expiry behavior in the official GHL developer documentation, as these details can change between API versions.
Recipes
Step 1: Register your app and build the authorization URL
In the GHL developer portal, create a new app, set your redirect URI, and select the scopes your app needs. Copy the client_id.
Build the authorization URL and redirect your user to it. GHL shows the user a consent screen listing the scopes your app is requesting.
Node.js: build the authorization URL
const GHL_CLIENT_ID = process.env.GHL_CLIENT_ID;
const REDIRECT_URI = 'https://yourapp.com/ghl/callback';
// Scopes your app needs, select only what you use
const SCOPES = [
'contacts.readonly',
'contacts.write',
'calendars.readonly',
'calendars.write',
'opportunities.readonly',
'opportunities.write',
'locations.readonly',
].join(' ');
export function GET(req) {
const params = new URLSearchParams({
client_id: GHL_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: SCOPES,
});
const authUrl = `https://marketplace.gohighlevel.com/oauth/chooselocation?${params}`;
return Response.redirect(authUrl, 302);
}Python (FastAPI)
import os
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from urllib.parse import urlencode
app = FastAPI()
GHL_CLIENT_ID = os.environ["GHL_CLIENT_ID"]
REDIRECT_URI = "https://yourapp.com/ghl/callback"
SCOPES = " ".join([
"contacts.readonly",
"contacts.write",
"calendars.readonly",
"calendars.write",
"locations.readonly",
])
@app.get("/connect-ghl")
def connect_ghl():
params = urlencode({
"client_id": GHL_CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": SCOPES,
})
return RedirectResponse(
url=f"https://marketplace.gohighlevel.com/oauth/chooselocation?{params}"
)Step 2: Handle the callback and exchange the code for tokens
GHL redirects back to your redirect URI with a code parameter. Exchange this code for an access token and refresh token at the token endpoint. The code is single-use and short-lived (usually a few minutes).
Store the access token, refresh token, expiry time, and the associated userId and locationId in your database. You need all of these to make API calls and to refresh the token later.
Node.js: callback handler
const GHL_CLIENT_ID = process.env.GHL_CLIENT_ID;
const GHL_CLIENT_SECRET = process.env.GHL_CLIENT_SECRET;
const REDIRECT_URI = 'https://yourapp.com/ghl/callback';
export async function GET(req) {
const url = new URL(req.url);
const code = url.searchParams.get('code');
if (!code) return Response.json({ error: 'Missing code' }, { status: 400 });
const res = await fetch('https://services.leadconnectorhq.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: GHL_CLIENT_ID,
client_secret: GHL_CLIENT_SECRET,
redirect_uri: REDIRECT_URI,
code,
grant_type: 'authorization_code',
}),
});
const tokens = await res.json();
// tokens = { access_token, refresh_token, token_type, expires_in, locationId, userId }
// Store in your database
await db.upsert('ghl_tokens', {
userId: tokens.userId,
locationId: tokens.locationId,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
});
return Response.redirect('/dashboard', 302);
}Python (FastAPI)
import os
import time
import requests
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
app = FastAPI()
GHL_CLIENT_ID = os.environ["GHL_CLIENT_ID"]
GHL_CLIENT_SECRET = os.environ["GHL_CLIENT_SECRET"]
REDIRECT_URI = "https://yourapp.com/ghl/callback"
@app.get("/ghl/callback")
def ghl_callback(code: str):
res = requests.post(
"https://services.leadconnectorhq.com/oauth/token",
data={
"client_id": GHL_CLIENT_ID,
"client_secret": GHL_CLIENT_SECRET,
"redirect_uri": REDIRECT_URI,
"code": code,
"grant_type": "authorization_code",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
res.raise_for_status()
tokens = res.json()
# Store in your database (pseudocode)
save_tokens(
user_id=tokens["userId"],
location_id=tokens["locationId"],
access_token=tokens["access_token"],
refresh_token=tokens["refresh_token"],
expires_at=time.time() + tokens["expires_in"],
)
return RedirectResponse(url="/dashboard")Step 3: Refresh the access token before it expires
Build a helper function that checks the token expiry before every API call and refreshes if needed. Call this function at the start of any route that makes GHL API requests.
A best practice is to refresh the token proactively when it is within a few minutes of expiry, not just after it expires. This avoids 401 errors mid-request.
Node.js: token refresh helper
async function getValidToken(locationId) {
const stored = await db.findOne('ghl_tokens', { locationId });
if (!stored) throw new Error('No token for this location');
// Refresh if less than 5 minutes remaining
const BUFFER = 5 * 60 * 1000;
if (stored.expiresAt, Date.now() < BUFFER) {
const res = await fetch('https://services.leadconnectorhq.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GHL_CLIENT_ID,
client_secret: process.env.GHL_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: stored.refreshToken,
}),
});
const tokens = await res.json();
await db.update('ghl_tokens', { locationId }, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
});
return tokens.access_token;
}
return stored.accessToken;
}
// Usage in an API route
export async function GET(req, { params }) {
const { locationId } = params;
const token = await getValidToken(locationId);
const res = await fetch(`https://services.leadconnectorhq.com/contacts/search?locationId=${locationId}`, {
headers: { Authorization: `Bearer ${token}`, Version: '2021-07-28' },
});
return Response.json(await res.json());
}Common errors
- invalid_grant on token exchange: the code has expired or was already used. Codes are single-use and short-lived. If the user takes too long to click through the consent screen, they need to restart the flow.
- invalid_client: client_id or client_secret is wrong. Double-check the values in the developer portal. Do not confuse the live and test app credentials.
- 401 on API calls after token exchange: you are using the agency-level access token but the endpoint requires a location-level token. Call GET /oauth/installedLocations and then exchange for a location token.
- Refresh token rejected: the user has revoked your app's access, or the refresh token expired (some long-inactive tokens expire). Handle this by redirecting the user through the consent flow again.
MCP tool definition
MCP tool definition (JSON)
{
"name": "ghl_get_access_token",
"description": "Return a valid GoHighLevel access token for a given locationId, refreshing if needed.",
"inputSchema": {
"type": "object",
"properties": {
"locationId": { "type": "string", "description": "GHL sub-account ID" }
},
"required": ["locationId"]
}
}