Short answer
A reliable GoHighLevel sync has three parts: a receiver that verifies HighLevel’s signed webhooks and queues them, workers that call the API v2 endpoints within a per-location rate budget, and a scheduled reconciliation job that catches anything missed. The details that cause most failures are specific to HighLevel. Refresh tokens are single-use. Limits are counted per app per location (100 requests per 10 seconds and 200,000 per day). And every write you make comes back to you as a webhook.
HighLevel (GoHighLevel) is often the CRM at the centre of an agency’s stack, with forms, booking tools, billing systems and data warehouses all syncing contacts and opportunities in and out of it. Point-to-point automations work for one sub-account. They start losing data once you have dozens of locations, two-way sync and a busy afternoon. This article covers the HighLevel-specific engineering. The generic webhook principles are in Designing Resilient Multi-Platform Data Pipelines with n8n and Webhooks.
All limits and auth details below come from the HighLevel developer documentation as of October 2026. HighLevel changes these, so check them before you build.
The architecture
| Flow | Path | Main risk |
|---|---|---|
| Inbound | HighLevel webhook → receiver (verify, enqueue, 2xx) → worker → your system | Duplicates from HighLevel’s retries; spoofed requests |
| Outbound | Your change → queue → rate-limited worker → HighLevel API | 429s and exhausted daily budget; expired tokens |
| Reconciliation | Scheduled job compares recent changes on both sides | Silent drift nobody notices for weeks |
The queue between receiving and processing does the same job as in any webhook system. If you have not built one before, Scaling webhook ingestion with Redis and BullMQ walks through the receiver and worker code this article builds on.
Authentication: OAuth app or Private Integration
| OAuth 2.0 (Marketplace app) | Private Integration token | |
|---|---|---|
| Best for | Integrations installed across many agencies or sub-accounts | Your own agency or sub-account’s internal integration |
| Token lifetime | Access token about 24 hours; refresh token a year if unused | Static until you rotate it |
| Rotation | Every refresh returns a new refresh token and invalidates the old one | Manual; recommended every 90 days, with a 7-day overlap when scheduled |
| Header | Authorization: Bearer <token> plus a Version header |
|
The OAuth docs show expires_in of roughly 86,400 seconds for both Company (agency) and Location tokens. An agency-level token can be exchanged for a location token through POST /oauth/locationToken. Private Integration tokens are static OAuth access tokens with scopes you choose yourself. They are simpler, but they are a long-lived secret, so treat them like a password.
Refreshing tokens without a race
Refresh tokens are single-use. Each refresh returns a new pair and invalidates the previous refresh token. With several workers, two of them can notice an expiring token at the same moment. Both refresh, one of them stores a refresh token that is already dead, and the next refresh for that location fails until someone reinstalls the app. Serialise refreshes per location with a short lock:
// TypeScript, Node 20+, ioredis. tokenStore is your encrypted persistent store.
const TOKEN_URL = 'https://services.leadconnectorhq.com/oauth/token';
export async function getAccessToken(locationId: string): Promise<string> {
const t = await tokenStore.get(locationId);
if (t.expiresAt - Date.now() > 5 * 60_000) return t.accessToken;
const lockKey = `ghl:refresh-lock:${locationId}`;
const gotLock = await redis.set(lockKey, '1', 'PX', 30_000, 'NX');
if (!gotLock) {
await new Promise((r) => setTimeout(r, 1000));
return getAccessToken(locationId); // another worker is refreshing
}
try {
const fresh = await tokenStore.get(locationId); // re-read inside the lock
if (fresh.expiresAt - Date.now() > 5 * 60_000) return fresh.accessToken;
const res = await fetch(TOKEN_URL, {
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: fresh.refreshToken,
user_type: 'Location',
}),
});
if (!res.ok) throw new Error(`refresh failed ${res.status}`); // alert: may need reinstall
const body = await res.json();
await tokenStore.set(locationId, {
accessToken: body.access_token,
refreshToken: body.refresh_token, // persist the NEW one before using the access token
expiresAt: Date.now() + body.expires_in * 1000,
});
return body.access_token;
} finally {
await redis.del(lockKey);
}
}
Persist the new refresh token durably before anything else. If the process crashes after a successful refresh but before the write, the old refresh token is already invalid. Also refresh dormant locations on a schedule: a refresh token that goes unused for a year expires.
Rate limits: budget per location
HighLevel’s rate limits page sets two limits on API v2 endpoints authenticated with OAuth. Both are counted per Marketplace app per resource, where a resource is a single location (sub-account) or company (agency):
- Burst: 100 requests per 10 seconds.
- Daily: 200,000 requests per day.
Each response carries X-RateLimit-Remaining, X-RateLimit-Max, X-RateLimit-Interval-Milliseconds, X-RateLimit-Daily-Remaining and X-RateLimit-Limit-Daily. The docs recommend reading these instead of counting requests yourself.
Because the budget is per location, one global limiter is the wrong shape. A BullMQ worker limiter applies to the whole queue, so one busy location would throttle everyone else, and a queue per location does not scale to thousands of sub-accounts. A small per-location window in Redis, checked inside the job, handles both:
import { Worker, DelayedError, UnrecoverableError } from 'bullmq';
const WINDOW_MS = 10_000;
const BUDGET = 90; // stay under 100 per 10s to leave room for other callers
async function takeSlot(locationId: string): Promise<number> {
const windowStart = Math.floor(Date.now() / WINDOW_MS) * WINDOW_MS;
const key = `ghl:rl:${locationId}:${windowStart}`;
const used = await redis.incr(key);
if (used === 1) await redis.pexpire(key, WINDOW_MS * 2);
return used <= BUDGET ? 0 : windowStart + WINDOW_MS - Date.now(); // ms to wait
}
const worker = new Worker('ghl-outbound', async (job, token) => {
const { locationId } = job.data;
const waitMs = await takeSlot(locationId);
if (waitMs > 0) {
await job.moveToDelayed(Date.now() + waitMs + Math.random() * 500, token);
throw new DelayedError(); // not counted as a failed attempt
}
const res = await callHighLevel(job.data); // uses getAccessToken(locationId)
const dailyLeft = res.headers.get('x-ratelimit-daily-remaining');
if (dailyLeft !== null && Number(dailyLeft) < 1000) {
await markLocationLowBudget(locationId); // pause non-essential syncs
}
if (res.status === 429) throw new Error('rate limited'); // retried with backoff
if (res.status === 401) throw new Error('auth'); // refresh, then retry
if (res.status >= 400 && res.status < 500) throw new UnrecoverableError(`rejected ${res.status}`);
if (!res.ok) throw new Error(`HighLevel ${res.status}`);
}, { connection, concurrency: 20 });
The daily limit is the one that surprises people. A naive two-way sync that fetches the full contact after every webhook, then writes it back, can spend several calls per change. Plan the daily budget the way you would plan a database’s query budget: know which flows can be paused when a location runs low.
Receiving HighLevel webhooks
Verify the signature
The Webhook Integration Guide documents two headers. X-GHL-Signature is an Ed25519 signature and is the current scheme. X-WH-Signature is the legacy RSA-SHA256 signature, which the guide says is deprecated from 1 September 2026. New code should verify X-GHL-Signature against the Ed25519 public key published in the guide, and reject the request otherwise.
import crypto from 'node:crypto';
// Public key PEM copied from HighLevel's Webhook Integration Guide.
const GHL_ED25519_PUBLIC_KEY = process.env.GHL_WEBHOOK_PUBLIC_KEY!;
export function verifyGhlWebhook(rawBody: Buffer, signature?: string): boolean {
if (!signature) return false;
try {
return crypto.verify(null, rawBody, GHL_ED25519_PUBLIC_KEY, Buffer.from(signature, 'base64'));
} catch {
return false;
}
}
Verify against the raw request bytes. The guide’s own example re-serialises the parsed body, but re-serialising can change key order or whitespace and break verification. Capture the raw body before any JSON middleware runs.
Expect retries and deduplicate
The guide says HighLevel retries any non-2xx response, as well as timeouts and connection failures, up to 12 times with exponential backoff and jitter. So a slow receiver produces duplicates. Respond quickly, enqueue the work, and deduplicate on the webhookId included in the payload. Where an event lacks one, derive a key from the event type, the record id and its dateUpdated. The guide does not describe replay protection, so if that matters to you, enforce your own freshness window using the payload’s timestamp.
Two-way sync without echo loops
When your worker updates a contact through the API, HighLevel emits a ContactUpdate webhook for that change. Without a guard, your inbound worker applies it to your system, which emits its own change event, which updates HighLevel again. Three guards, in order of usefulness:
- Content hashes. After each write in either direction, store a hash of the synced fields for that record. When an event arrives whose hash matches the stored one, it is your own echo: drop it. This works no matter who triggered the change.
- Field ownership. Decide which system is authoritative for each field (for example, billing status from your platform, pipeline stage from HighLevel). Only sync owned fields in each direction. Most conflicts disappear.
- Last-write-wins on timestamps. Compare
dateUpdatedwith your own record’s modified time, and ignore older events that arrive late.
For contacts, prefer POST /contacts/upsert to search-then-create. Upsert matches existing contacts using the location’s “Allow Duplicate Contact” settings, so a retried job does not create a second contact. The same idea, that the store rather than the application enforces uniqueness, is behind preventing overselling in WooCommerce.
API versions
Every request carries a Version header. HighLevel’s versioning page lists date-based versions such as 2021-07-28 and a named v3 released on 11 June 2026, all currently without a retirement date. Some endpoint reference pages, including contact upsert, now list only v3. Set the version per endpoint from its reference page, keep it in one constant per endpoint group, and do not assume one value works everywhere.
Reconciliation
Webhooks will be missed: your receiver is down during a deploy, a location uninstalls and reinstalls, or a retry window runs out. Run a scheduled job per location that pulls records changed in a lookback window, compares their hashes with your stored state and queues repairs. Spread these jobs across the day, because they spend the same daily budget as live sync. Alert when a location’s count of differences climbs.
Checklist
- OAuth refresh serialised per location, with the new refresh token persisted before use and dormant locations refreshed on a schedule.
- Private Integration tokens kept in a secret manager and rotated on a calendar.
- Per-location limiter below 100 requests per 10 seconds, and daily-remaining headers watched.
X-GHL-Signatureverified with Ed25519 against the raw body.- Receiver acknowledges fast; jobs deduplicated on
webhookId. - Echo suppression with content hashes, plus field ownership documented.
Versionheader pinned per endpoint.- Reconciliation job per location, with drift alerts.
Questions people actually ask
- What are the GoHighLevel API rate limits?
- For API v2 endpoints using OAuth, HighLevel documents a burst limit of 100 requests per 10 seconds and a daily limit of 200,000 requests, both per Marketplace app per location or company. Read the
X-RateLimit-*response headers rather than relying on fixed numbers, because HighLevel can change them. - How long does a HighLevel access token last?
- About a day (
expires_inaround 86,400 seconds). The refresh token lasts a year if unused, and each refresh returns a new refresh token while invalidating the old one. - Should I use a Private Integration token or OAuth?
- Use a Private Integration token for an integration inside your own agency or sub-account. Use OAuth through a Marketplace app when other agencies or sub-accounts install your integration, because each install gets its own tokens and its own rate-limit budget.
Building or fixing a HighLevel integration?
I design GoHighLevel sync pipelines that survive token refreshes, rate limits and two-way updates across many sub-accounts, and I audit existing ones that have started to drift.