RocketLauncher AI

API and Recipes

GoHighLevel Outbound Custom Webhooks with Ed25519 Signature Verification

By Marnix Geerkens. Published 2026-05-28. Updated 2026-05-28.

In short

GoHighLevel outbound webhooks send a signed payload to a URL you control whenever a trigger fires (new contact, appointment booked, form submitted, and so on). GHL signs the payload with an Ed25519 private key. You verify the signature using the corresponding public key, which you get from GHL. Reject any request where verification fails.

  • GoHighLevel uses Ed25519 asymmetric signing for outbound webhook payloads.
  • The signature is in the x-ghl-signature header, base64-encoded.
  • The public key is available from GHL settings. Never use the raw payload without verifying it first.
  • Ed25519 is faster and more secure than HMAC-SHA256 for this use case because you only need the public key in your server.

Endpoints

MethodPathScopesRate limit
POSTYOUR_BACKEND_URL (receives from GHL)No GHL token needed on receiverDepends on your workflow trigger volume
GET/locations/{locationId}/customWebhookswebhooks.readonlySee GHL API docs
POST/locations/{locationId}/customWebhookswebhooks.writeSee GHL API docs

Authentication

Your receiver endpoint does not need a GHL token. GHL is the caller; you are the receiver. Authentication works the other way: you verify that the caller is GHL by checking the Ed25519 signature.

Get the public key from your GHL account. The exact location changes with UI updates, so check the current documentation. As of early 2026, it is found in Settings > Integrations > Webhooks or through the API.

Store the public key as an environment variable in your backend. Never hardcode it, and never commit it to source control (even though it is a public key, rotating it means you need to update it in one place).

If GHL rotates the signing key, you will start receiving 401 or verification failures. Build an alert for consistent verification failures so you know when to update the public key.

Recipe 1. Verify an Ed25519 signature in Node.js

Use Node's built-in crypto module. No extra packages needed in Node 18+. The signature comes in the x-ghl-signature header as a base64-encoded string. The message being signed is the raw request body as a Buffer.

Always read the raw body before parsing JSON. Parsing first then stringifying will change the byte order and break verification.

Node.js (built-in crypto)
import crypto from 'crypto';

const GHL_PUBLIC_KEY = process.env.GHL_WEBHOOK_PUBLIC_KEY;
// Format: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"

export async function POST(req) {
  const rawBody = Buffer.from(await req.arrayBuffer());
  const sigHeader = req.headers.get('x-ghl-signature');

  if (!sigHeader) {
    return new Response('Missing signature', { status: 401 });
  }

  const sigBuffer = Buffer.from(sigHeader, 'base64');
  const publicKey = crypto.createPublicKey({
    key: GHL_PUBLIC_KEY,
    format: 'pem',
    type: 'spki',
  });

  const isValid = crypto.verify(
    null, // Ed25519 does not use a digest algorithm
    rawBody,
    publicKey,
    sigBuffer
  );

  if (!isValid) {
    return new Response('Invalid signature', { status: 401 });
  }

  const payload = JSON.parse(rawBody.toString('utf8'));
  // Handle the payload safely
  console.log('Verified GHL event:', payload.type);
  return new Response('ok', { status: 200 });
}
Node.js (Express)
import express from 'express';
import crypto from 'crypto';

const app = express();
const GHL_PUBLIC_KEY = process.env.GHL_WEBHOOK_PUBLIC_KEY;

// IMPORTANT: use express.raw() to get the raw body for verification
app.post('/ghl-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body; // Buffer
  const sigHeader = req.headers['x-ghl-signature'];

  if (!sigHeader) return res.status(401).send('Missing signature');

  const sigBuffer = Buffer.from(sigHeader, 'base64');
  const publicKey = crypto.createPublicKey({
    key: GHL_PUBLIC_KEY,
    format: 'pem',
    type: 'spki',
  });

  const isValid = crypto.verify(null, rawBody, publicKey, sigBuffer);
  if (!isValid) return res.status(401).send('Invalid signature');

  const payload = JSON.parse(rawBody.toString('utf8'));
  console.log('GHL event:', payload.type, payload.contactId);
  res.send('ok');
});
Python (FastAPI)
import base64
import os
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from cryptography.exceptions import InvalidSignature
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
_raw_pem = os.environ["GHL_WEBHOOK_PUBLIC_KEY"].encode()
PUBLIC_KEY: Ed25519PublicKey = load_pem_public_key(_raw_pem)  # type: ignore

@app.post("/ghl-webhook")
async def ghl_webhook(request: Request):
    raw_body = await request.body()
    sig_header = request.headers.get("x-ghl-signature", "")
    if not sig_header:
        raise HTTPException(status_code=401, detail="Missing signature")

    try:
        sig_bytes = base64.b64decode(sig_header)
        PUBLIC_KEY.verify(sig_bytes, raw_body)
    except (InvalidSignature, Exception):
        raise HTTPException(status_code=401, detail="Invalid signature")

    import json
    payload = json.loads(raw_body)
    print("GHL event:", payload.get("type"))
    return {"status": "ok"}

Recipe 2. Create an outbound custom webhook via the API

You can also register the webhook URL programmatically instead of through the GHL UI. POST to /locations/{locationId}/customWebhooks with the URL and the trigger events you want to subscribe to.

Confirm the current list of supported event types in the official GHL API documentation. Event type names can change between API versions.

cURL
curl -X POST https://services.leadconnectorhq.com/locations/YOUR_LOCATION_ID/customWebhooks \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Version: 2021-07-28" \
  -d '{
    "name": "Payment processor sync",
    "url": "https://yourbackend.com/ghl-webhook",
    "actions": [
      "ContactCreate",
      "ContactUpdate",
      "AppointmentCreate",
      "FormSubmitted"
    ]
  }'
Node.js (fetch)
const res = await fetch(
  'https://services.leadconnectorhq.com/locations/YOUR_LOCATION_ID/customWebhooks',
  {
    method: 'POST',
    headers: {
      Authorization: 'Bearer YOUR_TOKEN',
      'Content-Type': 'application/json',
      Version: '2021-07-28',
    },
    body: JSON.stringify({
      name: 'Payment processor sync',
      url: 'https://yourbackend.com/ghl-webhook',
      actions: ['ContactCreate', 'ContactUpdate', 'AppointmentCreate'],
    }),
  }
);
const webhook = await res.json();
console.log('Created webhook:', webhook.id);

Recipe 3. Handle common GHL event types in one receiver

A single receiver endpoint can handle multiple event types by switching on the type field in the payload. Verify the signature first, then route to the right handler based on the event type.

Node.js (multi-event handler)
// After signature verification (see Recipe 1)
function handleGhlEvent(payload) {
  switch (payload.type) {
    case 'ContactCreate':
      return onContactCreated(payload);
    case 'AppointmentCreate':
      return onAppointmentBooked(payload);
    case 'FormSubmitted':
      return onFormSubmitted(payload);
    case 'OpportunityCreate':
      return onOpportunityCreated(payload);
    default:
      console.warn('Unhandled GHL event type:', payload.type);
  }
}

async function onContactCreated(payload) {
  const { contactId, email, firstName, locationId } = payload;
  // Sync to your CRM, data warehouse, or email tool
}

async function onAppointmentBooked(payload) {
  const { appointmentId, startTime, contactId } = payload;
  // Send to your internal calendar or SMS reminder service
}

Common errors and fixes

crypto.verify() returns false even for valid requests: you are parsing the body before verification. Use express.raw() or read the raw Buffer. Do not let your framework auto-parse JSON before you run verification.

InvalidKey error in Python: the PEM key has extra whitespace or missing newlines. The public key must have the correct PEM header, footer, and line breaks. Store it as a multi-line environment variable or load it from a file.

Receiving duplicate events: GHL retries failed webhook deliveries. If your handler is slow or times out, GHL may resend the event. Implement idempotency by storing the event ID and skipping already-processed events.

Missing x-ghl-signature header: your receiver URL is being tested directly (e.g. with curl) or the webhook was configured before GHL enabled signing. Check the GHL docs for whether signing is mandatory for your account type.

Copy as an MCP tool

MCP tool definition (JSON)
{
  "name": "verify_ghl_webhook",
  "description": "Verify a GoHighLevel Ed25519 webhook signature and return the parsed payload if valid.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "rawBody": { "type": "string", "description": "Raw request body as UTF-8 string" },
      "signatureHeader": { "type": "string", "description": "Value of x-ghl-signature header (base64)" },
      "publicKeyPem": { "type": "string", "description": "GHL Ed25519 public key in PEM format" }
    },
    "required": ["rawBody", "signatureHeader", "publicKeyPem"]
  }
}

You need a GoHighLevel account to use the API. Start the 30-day trial through our link.

Frequently asked questions

Why does GoHighLevel use Ed25519 instead of HMAC-SHA256?

Ed25519 is an asymmetric algorithm. GHL keeps the private key and you only need the public key. This means a compromised receiver cannot be used to forge events, because forging requires the private key. HMAC-SHA256 uses a shared secret, so a breach on either side breaks the trust.

Where do I find the GHL webhook public key?

Check Settings > Integrations > Webhooks in your GHL sub-account or agency account. The exact UI path may change. The official GHL API documentation has the current location. If you use OAuth, the public key may also be returned during the app authorization flow.

Do all GHL outbound webhooks use Ed25519 signing?

Signing behavior depends on how the webhook was created and the GHL plan. Confirm the current signing behavior for your account type and webhook configuration in the official GHL documentation, as this has changed across API versions.

How do I handle GHL retrying a webhook my server already processed?

Store the unique event ID from the payload in your database the first time you process it. On every incoming request, check if the ID already exists. If it does, return 200 without processing again. This is called idempotency.

Related reading

Inbound webhook: Stripe and Shopify payment eventsReceive payment events from external platforms.Build an MCP server on top of the GHL APIExpose GHL as a callable tool for AI agents.OAuth 2.0 marketplace app setupAuthenticate users via OAuth and access their sub-accounts.GoHighLevel API overviewAll API and webhook recipes.