My GoHighLevel API v2 integration died at 3 AM. The access token expired, my script botched saving the new refresh token, and the whole system went down. If you’ve built anything custom on GHL, you’ve probably lived this exact nightmare.
GHL’s API v2 uses a strict OAuth 2.0 flow. Access tokens last exactly 24 hours, and refresh tokens are strictly single-use. If your server restarts mid-refresh, or if two API calls try to refresh at the same millisecond, your refresh token is instantly burned. Your integration breaks, and you’re forced to manually log in to re-authorize. It’s incredibly frustrating.
Let’s fix this. We’ll build a bulletproof Node.js token manager using Express. It handles the initial OAuth handshake, stores tokens in a local JSON file (which you can easily swap for Redis or Postgres later), and automatically refreshes the access token before making any API calls.

Why GoHighLevel’s OAuth Breaks in Production
The biggest pain point with the GoHighLevel API v2 is that refresh tokens are single-use. The moment you request a new access token, GHL invalidates the old refresh token and hands you a new one. You have to save both instantly.
If your app crashes mid-write, or if a concurrent request fires off using the old token, the API spits back an invalid_grant error. Once you hit invalid_grant, game over. You have to manually redirect the user to the auth screen. To stop this, we need a solid storage file and a pre-flight check that verifies token expiration before we even touch the API.
If you’re dealing with high-volume production webhooks, secure session state is a must. Check out our guide on how to store Express sessions in Redis for a production-grade setup.
Designing a Simple Token Store
We need a persistent place to keep our tokens. We’ll use a local JSON file here to keep things simple and dependency-free. In production, swap this out for Redis or a proper database to handle multi-instance deployments.
Our store needs to track four things:
- access_token: The active token used in the Authorization header.
- refresh_token: The single-use token used to request a new access token.
- expires_at: A Unix timestamp (in milliseconds) indicating when the access token expires.
- location_id: The specific HighLevel location associated with these tokens.
Setting Up the Project
Let’s initialize a new Node project and grab our dependencies. We’ll use Express for the redirect server and Axios to hit the GHL API.
Run these commands in your terminal:
mkdir ghl-oauth-manager
cd ghl-oauth-manager
npm init -y
npm install express axios dotenvNext, drop a .env file in your root folder. This holds your GHL App credentials from your Developer Marketplace account.
PORT=3000
GHL_CLIENT_ID=your_client_id_here
GHL_CLIENT_SECRET=your_client_secret_here
GHL_REDIRECT_URI=http://localhost:3000/oauth/callbackStep 1: The OAuth Redirect and Initial Exchange
When a user installs your app, GHL redirects them to your redirect URI with a temporary authorization code in the URL. We need to grab this code and immediately swap it for our first set of tokens.
Create a server.js file and add this code to spin up Express and handle the callback:
const express = require('express');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
const TOKEN_FILE = path.join(__dirname, 'tokens.json');
app.use(express.json()); // Helper to read tokens
function readTokens() {
if (!fs.existsSync(TOKEN_FILE)) return null;
try {
return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'));
} catch (err) {
return null;
}
}
// Helper to write tokens
function saveTokens(tokens) {
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), 'utf8');
}
app.get('/oauth/callback', async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).send('Missing authorization code.');
}
try {
const response = await axios.post('https://services.leadconnectorhq.com/oauth/token', new URLSearchParams({
client_id: process.env.GHL_CLIENT_ID,
client_secret: process.env.GHL_CLIENT_SECRET,
grant_type: 'authorization_code',
code: code,
redirect_uri: process.env.GHL_REDIRECT_URI
}), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
const data = response.data;
const tokenData = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + (data.expires_in * 1000),
location_id: data.locationId || data.companyId
};
saveTokens(tokenData);
res.send('Authorization successful! Tokens have been saved.');
} catch (error) {
console.error('OAuth Exchange Error:', error.response ? error.response.data : error.message);
res.status(500).send('Failed to exchange authorization code.');
}
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});This endpoint catches the GET request on /oauth/callback, extracts the code, and POSTs it to GHL’s token endpoint. We calculate the exact expiration timestamp by adding the expires_in value (usually 24 hours, or 86400 seconds) to the current time.
Step 2: Writing the Auto-Refresh Logic
Now for the core engine: a helper function that checks if our access token is dead or about to die. If it’s expired, it automatically requests a new one using our stored refresh token, updates our JSON file, and returns the fresh access token.
Add this helper to your server.js:
async function getValidAccessToken() {
const tokens = readTokens();
if (!tokens) {
throw new Error('No tokens found. Please authorize the application first.');
}
// Check if token expires in less than 5 minutes (300,000 ms) to be safe
const isExpired = Date.now() + 300000 >= tokens.expires_at;
if (!isExpired) {
return tokens.access_token;
}
console.log('Access token expired or expiring soon. Refreshing...');
try {
const response = await axios.post('https://services.leadconnectorhq.com/oauth/token', new URLSearchParams({
client_id: process.env.GHL_CLIENT_ID,
client_secret: process.env.GHL_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token
}), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
const data = response.data;
const updatedTokens = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + (data.expires_in * 1000),
location_id: data.locationId || data.companyId
};
saveTokens(updatedTokens);
console.log('Tokens refreshed successfully.');
return updatedTokens.access_token;
} catch (error) {
console.error('Failed to refresh token:', error.response ? error.response.data : error.message);
throw new Error('Token refresh failed. Manual re-authorization may be required.');
}
}Notice the 5-minute (300,000 ms) buffer. Don’t wait until the absolute last second to refresh. Giving yourself a buffer prevents edge-case failures where a token expires mid-transit.
Step 3: Making Safe API Calls
With our helper ready, hitting GHL is incredibly clean. We just call getValidAccessToken() right before every single API request. This guarantees we never send an expired token.
Here’s an example route to pull contacts from your GHL location:
app.get('/contacts', async (req, res) => {
try {
const accessToken = await getValidAccessToken();
const tokens = readTokens();
const response = await axios.get('https://services.leadconnectorhq.com/contacts/', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Version': '2021-07-28'
},
params: { locationId: tokens.location_id, limit: 20 }
});
res.json(response.data);
} catch (error) {
console.error('API Request Error:', error.message);
res.status(500).json({ error: error.message });
}
});This route grabs the active token, pulls the location ID from our store, and fetches the contacts. If the token was expired, the refresh happens completely in the background without the user ever noticing.
Beating Race Conditions in Production
In production, you will hit race conditions. If you get five webhooks at the exact same millisecond, your server will trigger five concurrent API requests. If your token is expired, all five will try to refresh it at once.
Because GHL refresh tokens are single-use, the first request will succeed, and the other four will fail with invalid_grant because they used the old, now-invalidated refresh token. This will completely break your integration.
To prevent this, we need a locking mechanism. If a refresh is already running, other requests must wait for that specific promise to resolve instead of firing their own HTTP calls. For more on handling concurrent webhook issues, check out our guide on preventing duplicate webhook processing with Redis.
Here is how we can implement an in-memory promise lock:
let refreshPromise = null;
async function getValidAccessTokenWithLock() {
const tokens = readTokens();
if (!tokens) throw new Error('No tokens found.');
const isExpired = Date.now() + 300000 >= tokens.expires_at;
if (!isExpired) return tokens.access_token; // If a refresh is already happening, wait for it
if (refreshPromise) {
console.log('Refresh already in progress, waiting...');
return refreshPromise;
}
// Create the refresh promise
refreshPromise = (async () => {
try {
const response = await axios.post('https://services.leadconnectorhq.com/oauth/token', new URLSearchParams({
client_id: process.env.GHL_CLIENT_ID,
client_secret: process.env.GHL_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token
}), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
const data = response.data;
const updatedTokens = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + (data.expires_in * 1000),
location_id: data.locationId || data.companyId
};
saveTokens(updatedTokens);
return updatedTokens.access_token;
} finally {
// Clear the lock when finished
refreshPromise = null;
}
})();
return refreshPromise;
}This simple memory lock ensures only one refresh request goes out to GHL. Any other concurrent requests wait for that same promise to resolve and use the newly fetched token.
How to Test This Locally
To test this locally, you need to expose your local server to the web so GHL can hit your redirect callback. I usually use Cloudflare Tunnels for this.
For a step-by-step setup, see our guide on how to test webhooks locally with Cloudflare Tunnels.
Once your tunnel is live, update GHL_REDIRECT_URI in your .env file and make sure it matches the redirect URI in your GHL Developer App settings.
FAQ
What happens if my refresh token expires?
GHL refresh tokens technically last 365 days, but remember they are single-use. If your app fails to save the new one during a refresh, or if you don’t run a single refresh for a year, it’s dead. You’ll have to send the user back through the manual OAuth screen to get a new authorization code.
Can I use this flow for multiple locations?
Yes, but you’ll need to upgrade your storage. Instead of a single JSON file, store them in a database table keyed by location_id. Your getValidAccessToken() helper should accept a locationId parameter to fetch and refresh the correct token pair for that specific account.
How do I handle webhook security with this setup?
When GHL sends webhooks to your server, you must verify they actually came from GHL. Secure your routes by validating signatures. For more on webhook architecture, check out how to route webhooks to multiple endpoints.
Wrapping Up
Now that you’ve automated your GoHighLevel API v2 OAuth flow, you can build background integrations without worrying about random 3 AM authentication failures. If you want to take this further, check out our guide on how to auto-tag GoHighLevel contacts using a local Ollama AI agent.
Disclaimer: If you are looking to get started with the platform, you can sign up using my GoHighLevel affiliate link. I may earn a commission at no extra cost to you.

