RocketLauncher AI

API and Recipes

GoHighLevel OAuth 2.0 Marketplace App Setup

By Marnix Geerkens. Published 2026-05-28. Updated 2026-05-28.

In short

A GoHighLevel marketplace app uses OAuth 2.0 to let GHL users authorize your app to access their sub-account data. You register the app in the GHL developer portal, redirect the user to GHL's authorization URL, exchange the returned code for access and refresh tokens, then call the GHL API using those tokens. Each user gets their own tokens; you never share a single token across accounts.

  • 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

MethodPathScopesRate limit
GEThttps://marketplace.gohighlevel.com/oauth/chooselocation (browser redirect)None (start of OAuth flow)n/a
POSThttps://services.leadconnectorhq.com/oauth/tokenNone (token exchange)See GHL API docs
GEThttps://services.leadconnectorhq.com/oauth/installedLocationslocations.readonlySee 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.

Recipe 1. 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}"
    )

Recipe 2. 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")

Recipe 3. 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 and fixes

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.

Copy as an MCP tool

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"]
  }
}

You need a GoHighLevel account to use the API. Start the 30-day trial through our link.

Frequently asked questions

Do I need a marketplace app or can I use a private integration token?

Use a private integration token for tools you build for your own sub-accounts. Use a marketplace app with OAuth when you want other GHL users to install and authorize your app. Private tokens are simpler but only work for the specific sub-account they belong to.

What scopes should I request?

Request only the scopes your app actually uses. GHL shows users the list of scopes on the consent screen, and requesting too many raises suspicion. Start with the minimum and expand later. Check the current scope list in the GHL developer portal, as available scopes change with new API releases.

How do I handle multi-location agencies?

After the initial OAuth flow, call GET /oauth/installedLocations to get all sub-accounts where the user installed your app. You can then exchange the agency token for a location-specific token for each sub-account. Store all tokens separately, keyed by locationId.

What happens if a user uninstalls my app?

GHL sends an app.deleted webhook event to your notification URL. Listen for this event and delete the stored tokens for that locationId. Subsequent API calls with the old token will fail with 401.

Related reading

Build an MCP server on top of the GHL APIUse OAuth tokens in your MCP server for multi-tenant deployments.Contacts create and updateMake API calls with your OAuth tokens.Outbound custom webhook with Ed25519 signatureReceive signed events from GHL in your app backend.GoHighLevel API overviewAll API and webhook recipes.