GoHighLevel webhooks let your app react the instant something happens in a CRM: a contact is created, an opportunity changes stage, a payment fails. Instead of polling the API on a timer and burning your rate limit, you register an HTTPS endpoint, pick the events you care about, and GHL posts to you. This guide covers the three things that actually matter in production: subscribing to the right events, verifying the signature so you can trust the payload, and handling retries without processing the same event twice.
The one part people get wrong is verification. GoHighLevel signs every webhook, and in 2026 the signature scheme is changing, so let's get that right first and build the rest around it.
Subscribing to events
You register a public HTTPS endpoint and select the event types you want. For a Marketplace app, you configure webhook events in the app settings; for account-level automations you can also drive webhooks from workflows. Keep the event list tight. Subscribing to everything means your endpoint gets hammered with events you throw away, which wastes compute and makes debugging harder. Pick the two or three events your feature genuinely needs.
Verify the signature, or trust nothing
Anyone who learns your endpoint URL can POST fake data to it. Signature verification is what proves a request really came from GoHighLevel. GHL signs webhooks with a public/private key pair: they sign with their private key, you verify with their public key.
Here's the important 2026 detail. GHL is moving to Ed25519 signatures sent in the X-GHL-Signature header. The legacy X-WH-Signature header, which used RSA, is being deprecated on 1 July 2026. After that, webhooks are signed only with X-GHL-Signature. So verify the new header, and treat the old one as a temporary fallback during the transition.
import crypto from 'node:crypto';
// The GHL Ed25519 public key (from the HighLevel webhook docs). Store it in config.
const GHL_PUBLIC_KEY = process.env.GHL_WEBHOOK_PUBLIC_KEY;
function verifyGhlSignature(rawBody, signatureB64) {
const key = crypto.createPublicKey(GHL_PUBLIC_KEY);
return crypto.verify(
null, // Ed25519 uses no separate hash algorithm
Buffer.from(rawBody), // the RAW request body, not the parsed JSON
key,
Buffer.from(signatureB64, 'base64'),
);
}
Two things make or break this. First, verify against the raw request body, the exact bytes GHL sent, not a re-serialized version of the parsed JSON. If your framework parses the body before you see it, capture the raw buffer first (in Express, use express.raw() on the webhook route). Re-stringifying req.body reorders keys and changes whitespace, and the signature won't match. Second, reject anything that fails verification with a 401 and move on.
app.post('/webhooks/ghl', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.get('X-GHL-Signature');
if (!sig || !verifyGhlSignature(req.body, sig)) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString());
enqueue(event); // hand off to a background job (see below)
res.status(200).send('ok'); // acknowledge fast
});
Acknowledge fast, process slow
Notice the handler above does almost nothing: verify, enqueue, return 200. That's deliberate. If your endpoint does slow work (calling other APIs, writing lots of rows) before responding, GHL may time out waiting and treat the delivery as failed. So acknowledge within a second or two, then do the real work in a background job or queue. Your webhook endpoint's only job is to accept and verify.
Handle retries with idempotency
GoHighLevel automatically retries deliveries that fail or time out. That's good for reliability and it means you will sometimes receive the same event more than once. If your handler isn't idempotent, a retry can double-charge, double-send, or create duplicate records.
The fix is to make processing safe to repeat. Give each event a stable identity and record what you've already handled:
async function processEvent(event) {
const eventId = event.webhookId ?? `${event.type}:${event.id}:${event.timestamp}`;
// Skip if we've already handled this exact event.
const seen = await db.processedEvents.findUnique({ where: { eventId } });
if (seen) return;
await handleByType(event); // your actual logic
await db.processedEvents.create({ data: { eventId } });
}
Build idempotency in from the first line of code, not after the first duplicate causes a support ticket. It's much harder to retrofit once real data is flowing.
A checklist before you ship
- Endpoint is HTTPS and publicly reachable.
- You capture the raw body and verify
X-GHL-Signaturewith the Ed25519 public key. - You fall back to
X-WH-Signatureonly during the transition, and drop it after 1 July 2026. - You return 200 within a second or two, then process asynchronously.
- Every event is deduplicated by a stable id, so retries are safe.
- Failed verification returns 401; unexpected event types are logged, not crashed on.
Get those six right and your webhook layer is boring, which is exactly what you want. For where webhooks fit in the bigger build, see the GoHighLevel custom development guide, and if you're still polling because you haven't moved off the old API, read GoHighLevel API v2 vs v1 first.
Frequently asked questions
Which signature header should I verify?
Verify X-GHL-Signature using the GHL Ed25519 public key. The older X-WH-Signature (RSA) is deprecated on 1 July 2026, so only use it as a fallback until then.
Why does my signature check keep failing?
Almost always because you're verifying against parsed-and-re-stringified JSON instead of the raw request body. Capture the raw bytes before any JSON middleware runs, and verify those.
Does GoHighLevel retry failed webhooks?
Yes, it retries failed or timed-out deliveries automatically. That's why your handler must be idempotent, so a retried event doesn't get processed twice.
Should I do work inside the webhook handler?
No. Verify, enqueue, and return 200 quickly. Do the actual processing in a background job so a slow task never causes a delivery timeout and an unnecessary retry.




