Queue GoHighLevel Webhooks with Node.js and BullMQ

by Fahim

If your server takes more than a few seconds to process a GoHighLevel webhook, GHL will time out and start spamming you with retries. Let’s fix this. We’ll build a bulletproof webhook queue using Node.js, Express, and BullMQ to ingest events instantly and handle the heavy lifting in the background.

Server rack with glowing LED lights symbolizing high-speed queue processing
Server rack with glowing LED lights symbolizing high-speed queue processing

The GoHighLevel Webhook Timeout Problem

When you’re building integrations, GHL workflows trigger webhooks for everything—contact updates, pipeline shifts, form submissions. If your endpoint tries to do heavy lifting inline (like querying a slow database, hitting third-party APIs, or generating PDFs), the response time spikes. GHL wants a fast HTTP 200 response. If you keep it waiting for more than a couple of seconds, it drops the connection and retries. Suddenly, you’re dealing with duplicate data and wasted resources.

I learned this the hard way when a client triggered a bulk action that updated 2,500 contacts at once. My Express server choked, CPU spiked to 100%, and GHL started retrying the failed requests, creating a self-inflicted DDoS loop. The fix is simple: decouple ingestion from processing.

With a queue, your ingestion server does one job: accepts the payload, dumps it into Redis, and immediately shoots back a 200 OK. A separate worker process then pulls jobs from the queue and processes them at its own pace.

Setting Up Project Dependencies

We’ll need a basic Node.js setup with Express, BullMQ, and the ioredis client. Let’s spin up a new directory and initialize it.

Run this in your terminal to get the packages installed:

mkdir ghl-webhook-queue
cd ghl-webhook-queue
npm init -y
npm install express bullmq ioredis dotenv
npm install --save-dev nodemon

We’re using ioredis because BullMQ needs it to handle connection pooling under the hood. Next, drop a .env file into your root directory:

PORT=3000
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
QUEUE_NAME=ghl-webhooks

Make sure you have Redis running. If you don’t have it installed locally, you can spin it up in two seconds with Docker: docker run -d -p 6379:6379 redis.

Configuring Redis and BullMQ

Before writing the server, we need a shared Redis connection. If you don’t share the connection, your app can easily exhaust Redis resources by opening too many concurrent connections under heavy load.

Create a file called queueConnection.js to export the connection and queue instance:

const { Queue } = require('bullmq');
const IORedis = require('ioredis');
require('dotenv').config(); const redisConnection = new IORedis({ host: process.env.REDIS_HOST || '127.0.0.1', port: process.env.REDIS_PORT || 6379, maxRetriesPerRequest: null,
}); const webhookQueue = new Queue(process.env.QUEUE_NAME || 'ghl-webhooks', { connection: redisConnection, defaultJobOptions: { attempts: 3, backoff: { type: 'exponential', delay: 5000, }, removeOnComplete: true, removeOnFail: 1000, }
}); module.exports = { webhookQueue, redisConnection };

Don’t skip the maxRetriesPerRequest: null setting—BullMQ will literally throw an error without it. We also set up default job options to retry failed jobs three times with an exponential backoff starting at 5 seconds.

Building the Express Ingestion Server

Now for the Express server. Its only job is to receive the POST request from GHL, verify there’s a payload, push it onto our BullMQ queue, and return a 200 OK in milliseconds.

Create server.js and add this code:

const express = require('express');
const { webhookQueue } = require('./queueConnection');
require('dotenv').config(); const app = express();
app.use(express.json()); app.post('/webhooks/ghl', async (req, res) => { const payload = req.body; if (!payload || Object.keys(payload).length === 0) { return res.status(400).json({ error: 'Payload is empty' }); } try { const jobId = `${payload.type || 'event'}-${payload.contact_id || Date.now()}`; await webhookQueue.add('ghl-event', payload, { jobId: jobId }); return res.status(200).json({ success: true, message: 'Webhook received and queued', jobId }); } catch (error) { console.error('Failed to queue webhook:', error); return res.status(500).json({ error: 'Internal server error' }); }
}); const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Ingestion server listening on port ${PORT}`);
});

Generating a custom jobId from the event type and contact ID is a lifesaver. GHL sometimes fires duplicate webhooks back-to-back. With this setup, BullMQ automatically ignores duplicates if a job with that ID is already waiting in the queue.

If you want to lock down your endpoints before pushing this to production, check out our guide on how to build a secure GoHighLevel webhook listener with Node.js.

Writing the BullMQ Worker

With our ingestion server ready, we need a worker to actually do the work. This runs in a completely separate process. If the worker crashes or gets bogged down, your Express server doesn’t care—it keeps accepting webhooks without breaking a sweat.

Create worker.js to process the queue:

const { Worker } = require('bullmq');
const { redisConnection } = require('./queueConnection');
require('dotenv').config(); const processGHLWebhook = async (job) => { console.log(`[Worker] Processing job ${job.id} for event: ${job.data.type}`); // Simulate a heavy task like calling GHL API or updating a database await new Promise((resolve) => setTimeout(resolve, 2500)); if (job.data.type === 'fail_test') { throw new Error('Simulated processing failure'); } console.log(`[Worker] Job ${job.id} processed successfully`);
}; const worker = new Worker( process.env.QUEUE_NAME || 'ghl-webhooks', async (job) => { await processGHLWebhook(job); }, { connection: redisConnection, concurrency: 5, }
); worker.on('completed', (job) => { console.log(`[Worker] Job ${job.id} completed`);
}); worker.on('failed', (job, err) => { console.error(`[Worker] Job ${job.id || 'unknown'} failed: ${err.message}`);
}); console.log('Worker process started and waiting for jobs...');

I set the concurrency to 5 here, meaning the worker handles up to five jobs in parallel. If your jobs make heavy API calls to GHL, you’ll want to tune this number so you don’t accidentally hit rate limits.

Testing the Setup Locally

Let’s test this locally. We’ll run the Express server and the worker process in separate terminals. In production, you’d run these as separate background processes using PM2 or Docker.

Open your first terminal and start the server:

node server.js

Open a second terminal and start the worker:

node worker.js

Now, fire a mock POST request to your server using curl or Postman to simulate GHL:

curl -X POST http://localhost:3000/webhooks/ghl 
-H "Content-Type: application/json" 
-d '{ "type": "contact_create", "contact_id": "12345", "first_name": "John", "last_name": "Doe", "email": "john@example.com"
}'

Check your logs. The Express server should respond with a 200 OK almost instantly (usually in under 15ms). Over in your second terminal, you’ll see the worker pick up the job, wait out the simulated delay, and log the success.

Handling Failures, Retries, and GHL Rate Limits

When you’re dealing with GHL, you will hit API rate limits. If your worker makes too many calls too quickly, GHL will start throwing 429 status codes at you.

We can handle this gracefully using BullMQ’s exponential backoff. If a job fails due to a rate limit, we throw an error, and BullMQ schedules a retry later. For a deeper dive into managing API limits, check out our guide on how to handle external API rate limits with BullMQ and Redis.

Let’s update the processing function in worker.js to throw errors when we hit a rate limit, triggering our backoff strategy:

const processGHLWebhook = async (job) => { try { // Example API call to GoHighLevel // const response = await ghlClient.getContact(job.data.contact_id); // Simulating a rate limit response from GHL API const isRateLimited = Math.random() < 0.2; // 20% chance to simulate rate limit if (isRateLimited) { const rateLimitError = new Error('Rate limit exceeded (429)'); rateLimitError.code = 'RATE_LIMIT'; throw rateLimitError; } console.log(`[Worker] Successfully processed contact: ${job.data.contact_id}`); } catch (error) { if (error.code === 'RATE_LIMIT') { console.warn(`[Worker] Hit rate limit for job ${job.id}. Will retry...`); } throw error; // Throwing the error tells BullMQ to retry the job }
};

Since we configured attempts: 3 and exponential backoff in queueConnection.js, BullMQ will wait 5 seconds before the first retry, 10 seconds before the second, and 20 seconds before the third. This gives the GHL API time to cool down without losing your webhook data.

If you want to optimize how Redis manages memory under high load, check out our Redis caching tutorial for Node.js.

Frequently Asked Questions

Why not use a simple memory array instead of Redis?

Because if your Node.js process crashes, restarts, or you deploy new code, everything in memory is gone. Redis persists the queue data to disk, so you don’t lose jobs during server restarts.

How do I handle duplicate webhooks from GoHighLevel?

Use unique job IDs in BullMQ. By setting the jobId to a unique string (like combining the event type and contact ID), BullMQ automatically ignores duplicate payloads sent within the deduplication window.

Can I run the server and worker on different servers?

Absolutely. That’s the beauty of this architecture. You can host the Express ingestion server on a cheap, highly scalable instance to handle incoming traffic, and run your workers on separate, beefier servers optimized for background tasks.

What happens if Redis goes down?

If Redis goes down, your Express server will fail to queue jobs and return 500 errors. To prevent this, use a managed Redis service with high availability, or write a quick fallback that dumps payloads to a local SQLite database if the Redis connection drops.

Next Steps

Now that you’ve got a solid queue in place, you can scale your GHL integrations without stressing over timeouts or dropped payloads. To see how to put this queue system to use in complex real-world automation scenarios, check out our guide on GoHighLevel automations and advanced CRM workflows.

Official resources

all_in_one_marketing_tool