Most GoHighLevel integrations work fine until they meet real volume. A single sub-account with a steady drip of leads handles natively built Zaps and Make scenarios without complaint. Add twenty sub-accounts and a marketing campaign that fires simultaneously across all of them, and those same connectors start dropping events, timing out mid-execution, and returning errors with no retry logic in sight.
This guide is for the engineers and technical agency operators who have already hit that ceiling or are building for a scale where hitting it is inevitable. We will walk through why native connectors fail, how to architect middleware that survives real load, and the specific code patterns that keep 100+ sub-account integrations stable over time. This is also the territory where GoHighLevel experts with deep API experience earn their keep, and where agency fulfillment providers need this architecture in place to deliver at scale reliably.
Why Native HighLevel Connectors Break at Scale
The three failure modes show up in a predictable order as sub-account count and event volume grow. Understanding each one tells you what you are actually solving for when you build custom middleware.
Payload timeouts during burst events
When GoHighLevel fires a webhook, it expects a response within a short window, typically under five seconds. Native connectors in Zapier and Make run each webhook synchronously: the platform receives the event, executes the action steps, and only then sends the HTTP 200 back to HighLevel. On a quiet day with simple actions, that sequence completes in under a second. Add a CRM lookup, a data transformation, and a write-back to a third-party tool, and the same sequence can take ten to fifteen seconds. HighLevel marks the delivery as failed and may retry, generating duplicate events or just dropping the trigger silently depending on the webhook configuration.
The correct fix is not to speed up the processing. It is to decouple acknowledgment from processing. Your endpoint receives the event, writes it to a queue, and immediately returns HTTP 200. Processing happens asynchronously. GoHighLevel sees a healthy delivery. Your system handles the work on its own timeline.
API v2 rate limits per location
GoHighLevel API v2 enforces a default rate limit of 100 requests per 10-second window, measured per location (sub-account). Burst capacity is allowed, so short spikes above the limit do not always trigger an error. But any integration that fans out across multiple sub-accounts at the same time, such as a bulk contact import, a campaign launch, or a snapshot push, can exhaust the per-location budget quickly. The API returns HTTP 429 with a Retry-After header when the limit is hit.
Native connectors have no built-in rate-limit awareness. A Zapier action that hits 429 fails the task and logs an error. You have to notice it, investigate, and re-run manually. Custom middleware catches 429 responses, reads the Retry-After value, and reschedules the request automatically. Over 100 sub-accounts, that difference is the line between a system that runs unattended and one that needs a human watching it.
Unhandled edge cases during heavy webhook triggers
HighLevel fires webhooks for a wide range of events: contact created, contact updated, opportunity status changed, appointment booked, form submitted, and more. Each event type has a different payload shape, and many have optional fields that appear only under specific conditions. A native connector built against one example payload breaks the first time a real contact record has an empty phone field, a custom field array with unexpected keys, or a tag list that did not exist when the integration was built. There is no schema validation, no graceful degradation, and no alerting. The event is dropped.
Custom middleware lets you write explicit parsing logic, validate payload structure before processing, and route malformed events to a dead-letter queue for inspection rather than silent failure.
Architectural Diagram: Building Middleware for High-Volume Data Pipelines
The architecture that survives at scale is not complicated, but it requires choosing the right tools for each layer. Here is the pattern we use across agency integrations managing 100+ HighLevel sub-accounts.
The core layers
- Webhook receiver: a lightweight HTTP endpoint, either a Node.js/Express server or a Cloudflare Worker, whose only job is to validate the incoming request, write the payload to a queue, and return HTTP 200 within milliseconds.
- Event queue: a durable message queue (SQS, Cloudflare Queues, or BullMQ on Redis) that holds events until a worker picks them up. This is what separates acknowledgment from processing.
- Processing workers: stateless functions or long-running workers that dequeue events, apply business logic, and call the HighLevel API v2 for any write-back operations. These are where rate-limit retry logic lives.
- Dead-letter queue and logging: a separate queue for events that fail after all retry attempts, plus a log store for every event received and every API call made. Both are essential for debugging and auditing at scale.
Webhook receiver: Node.js with Express or Cloudflare Workers
For serverless deployments, Cloudflare Workers give you global edge presence with zero cold starts. For teams already running Node.js infrastructure, Express keeps the pattern simple. Here is a clean webhook receiver in Cloudflare Workers style, with HMAC signature validation:
// Cloudflare Worker: GoHighLevel webhook receiver
// Validates HMAC signature, enqueues event, returns 200 immediately
export default {
async fetch(request, env) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const rawBody = await request.text();
const signature = request.headers.get('x-ghl-signature');
// Validate HMAC-SHA256 signature using your webhook secret
const isValid = await validateSignature(rawBody, signature, env.GHL_WEBHOOK_SECRET);
if (!isValid) {
return new Response('Unauthorized', { status: 401 });
}
let payload;
try {
payload = JSON.parse(rawBody);
} catch (e) {
return new Response('Bad request', { status: 400 });
}
// Enqueue for async processing -- never block on this
await env.GHL_EVENT_QUEUE.send({
receivedAt: new Date().toISOString(),
eventType: payload.type,
locationId: payload.locationId,
payload
});
// Acknowledge immediately so HighLevel marks delivery as successful
return new Response('OK', { status: 200 });
}
};
async function validateSignature(body, signature, secret) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
const expected = btoa(String.fromCharCode(...new Uint8Array(mac)));
return expected === signature;
}
This handler validates the request, parses the JSON safely, and enqueues the event before returning. Total response time is under 10 milliseconds. GoHighLevel sees a reliable endpoint and stops retrying. Your queue holds every event for async processing at whatever pace your workers can sustain.
When Make or Zapier is already in the stack
If your agency runs automation in Make or Zapier and a full custom middleware layer is not on the table yet, the same decoupling principle applies with less code. Set up a custom webhook module in Make that receives the HighLevel event, stores it to a data store or a Google Sheet row, and immediately terminates the scenario with a success response. A separate scenario polls that store on a schedule and processes the queued events at a controlled rate. It is not as robust as a purpose-built queue, but it gives you acknowledgment/processing separation without standing up new infrastructure.
Step-by-Step Implementation: Catching and Parsing Custom Payloads
Once your receiver is live, the work moves to the processing layer. Here is how to parse a real GoHighLevel webhook event and write data back into the contact record.
What a contact.created payload looks like
Every contact.created event from HighLevel follows this structure:
{
"type": "contact.created",
"locationId": "loc_aBcDeFgH1234",
"id": "evt_xyz9876",
"contact": {
"id": "ctc_qRsTuVwX5678",
"firstName": "Jordan",
"lastName": "Lee",
"email": "[email protected]",
"phone": "+15550001234",
"source": "landing-page-form",
"tags": ["new-lead", "q3-campaign"],
"customFields": [
{ "id": "field_LeadScore", "value": "72" },
{ "id": "field_SourceCampaign", "value": "sept-webinar" }
]
}
}
The key fields for most integrations are locationId (which sub-account this came from), contact.id (used in all follow-up API calls), and customFields (an array, not an object, which trips up parsers expecting a fixed key structure). Always access customFields by iterating the array and matching on the id field. Never assume a position in the array.
Authenticating with GoHighLevel API v2
For integrations that act across multiple sub-accounts, OAuth2 is the right auth strategy. Each location gets its own access token, which you store and refresh independently. Here is the token exchange and a PUT call to write a custom field value back into the contact:
// Node.js: exchange OAuth2 code for location access token
async function getLocationToken(code) {
const response = 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: 'authorization_code',
code,
redirect_uri: process.env.GHL_REDIRECT_URI
})
});
return response.json(); // { access_token, refresh_token, locationId, ... }
}
// Write a custom field value back into a contact
async function updateContactField(locationId, contactId, fieldId, value, accessToken) {
const url = `https://services.leadconnectorhq.com/contacts/${contactId}`;
const response = await fetch(url, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Version': '2021-07-28' // required API v2 version header
},
body: JSON.stringify({
customFields: [{ id: fieldId, field_value: value }]
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(`GHL API error ${response.status}: ${JSON.stringify(error)}`);
}
return response.json();
}
Note the Version header. GoHighLevel API v2 requires it on every request. Omitting it falls back to v1 behavior on some endpoints and returns errors on others, often without a clear error message about the cause. Always include it explicitly.
Pushing data back into HighLevel sub-accounts
The pattern above works for individual custom field updates. For bulk writes across multiple contacts after processing a batch of events, use the same PUT endpoint per contact, but run the calls through your rate-limit queue rather than firing them concurrently. We will cover that queue in the next section. The contact ID from the webhook payload maps directly to the contactId path parameter, so no additional lookup is needed for most update operations.
Best Practices for Maintaining API Stability Across 100+ Sub-Accounts
The difference between an integration that runs for a week and one that runs for a year comes down to how you handle the things that are supposed to be fine but sometimes are not.
Structured logging at every step
Log the incoming webhook payload, the parsed event type, the sub-account it belongs to, and the result of every API call. Include timestamps, event IDs, and error codes. Use a structured format (JSON) so you can query by locationId, eventType, or status code when something goes wrong. You will not remember what happened three weeks ago without this. A service like Datadog, Logtail, or even a simple Postgres table works. The key requirement is queryable, not fancy.
Exponential backoff for rate-limit errors
When the HighLevel API returns HTTP 429, retry after the delay specified in the Retry-After response header. When the API returns 5xx errors (server errors), retry with exponential backoff: wait 1 second, then 2, then 4, then 8, up to a maximum delay and a maximum attempt count. After all retries are exhausted, send the event to a dead-letter queue rather than discarding it. Here is the retry wrapper:
// Exponential backoff retry for GHL API calls
async function withRetry(fn, maxAttempts = 5) {
let attempt = 0;
let delay = 1000; // start at 1 second
while (attempt < maxAttempts) {
try {
return await fn();
} catch (err) {
attempt++;
// On rate limit, respect the Retry-After header if available
if (err.status === 429 && err.retryAfter) {
await sleep(err.retryAfter * 1000);
continue;
}
// On server errors (5xx), use exponential backoff
if (err.status >= 500 && attempt < maxAttempts) {
await sleep(delay);
delay = Math.min(delay * 2, 30000); // cap at 30 seconds
continue;
}
// Client errors (4xx other than 429) are not retried
throw err;
}
}
throw new Error(`Max retry attempts (${maxAttempts}) exceeded`);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Wrap every outbound HighLevel API call in this function. The distinction between retryable errors (429, 5xx) and non-retryable ones (400, 401, 404) matters: retrying a 400 Bad Request will never succeed and wastes your rate-limit budget.
Per-location rate-limit queues
The 100 req/10s limit is per location, not per API key. Running 100 sub-accounts in parallel means you have 100 independent rate budgets, which is generous overall but requires per-location tracking. A naive implementation that fans out all sub-account calls from a single queue can exhaust any single location's budget while leaving others untouched. The correct approach is a queue per location (or a rate limiter keyed by locationId), so that bursts on one sub-account do not bleed into another.
Redis with a sliding window counter per locationId is the simplest implementation. Libraries like BullMQ support per-queue rate limits natively. For lower-scale integrations, a simple in-memory token bucket per locationId in a long-running Node.js process works until you need horizontal scaling.
Monitoring webhook delivery failures
GoHighLevel does not expose a delivery log for webhooks to external middleware. Your monitoring has to be built on your side. Two signals to watch: first, expected event gaps. If a location normally fires 20 contact.created events per day and you see zero for 48 hours, either the location is quiet or the webhook configuration changed. A simple job that checks event counts per location per day and alerts on drops catches most configuration drift. Second, dead-letter queue depth. If events are accumulating there, something systemic is broken, either your API credentials expired, a location was suspended, or an API schema changed.
Alert on dead-letter queue depth crossing a threshold, and send a daily digest of per-location event counts to whatever channel your team monitors. These two signals cover the majority of production failures before a client notices them.
Token refresh and credential rotation
OAuth2 access tokens from GoHighLevel expire. If your integration does not handle token refresh automatically, it will fail silently the first time a token expires, which is usually in the middle of the night three weeks after launch. Store the refresh token alongside the access token, check expiry before each API call, and refresh proactively when the token is within a few minutes of expiring. Log every refresh event so you have a record of when credentials cycled, which helps diagnose unexpected auth failures later.
Frequently Asked Questions
What is the GoHighLevel API v2 rate limit?
The default is 100 requests per 10-second window per location (sub-account). Some endpoints have stricter limits. Burst capacity is allowed, but sustained traffic above the threshold returns HTTP 429. Any integration across multiple sub-accounts needs rate-limit-aware retry logic.
Why do native GoHighLevel connectors fail under heavy webhook load?
Native connectors process webhooks synchronously and have no queue layer. When HighLevel fires a burst of events, the connector times out waiting for downstream processing to finish, then HighLevel marks the delivery as failed. The fix is middleware that acknowledges the webhook immediately and processes asynchronously.
How do I authenticate with the GoHighLevel API v2?
Two options: a static API key as a Bearer token for simple integrations, or OAuth2 for installed apps acting on behalf of multiple locations. For agency integrations across sub-accounts, OAuth2 is the right choice. The token endpoint is https://services.leadconnectorhq.com/oauth/token.
What fields does a GoHighLevel contact.created webhook payload include?
The payload includes event type, locationId, and a contact object with id, firstName, lastName, email, phone, tags, source, and customFields. The customFields field is an array of objects with id and value keys, not a flat object. Parse it by iterating and matching on the field id.
How do you monitor webhook delivery failures in GoHighLevel?
GoHighLevel does not expose a delivery log to external systems. Build monitoring on your side: log every received event with a status, alert on dead-letter queue depth, and run a daily job that checks per-location event counts against expected baselines. These two signals catch the majority of production failures before clients notice them.
Building integrations across 50+ HighLevel sub-accounts?
Our HighLevel Certified team architects and maintains custom API v2 integrations and webhook pipelines for agencies managing large sub-account portfolios. We handle the middleware, the monitoring, and the rate-limit logic so your team does not have to.
Book a call to scope your integration