Your HighLevel workflow runs, the webhook action fires, and your API immediately drops a 401 Unauthorized or 403 Forbidden in the logs. Out of the box, basic webhook steps dispatch plain, unauthenticated POST requests that any decent backend will reject on sight.
Here’s how to set up custom authorization headers in HighLevel workflows, manage your tokens cleanly using Custom Values, and verify incoming requests on your server without leaking secrets across sub-accounts.

Why Plain Webhooks Fail on Secure Endpoints
Whether you’re running Node.js, Python, Laravel, or a serverless handler on AWS Lambda, your backend needs to verify identity before it starts parsing user data. Blasting raw payloads to an unprotected endpoint leaves you wide open to spoofed data, replay attacks, and spam.
Older workflow nodes in GHL only gave you a single URL input field. Luckily, the Custom Webhook action lets you pass arbitrary key-value pairs in the request header. That means you can inject standard HTTP Authorization headers, API tokens, or sub-account identifiers straight into the HTTP envelope.
If your webhooks aren’t even making it out the door to your server, check our troubleshooting guide on fixing GoHighLevel workflow webhooks not firing first.
Store Your Secrets in HighLevel Custom Values First
Don’t paste raw API keys or static Bearer tokens directly into the webhook action step. If you duplicate the workflow, push an agency snapshot, or need to rotate a compromised key, you’ll be stuck manually updating every single action across all your sub-accounts.
Put your secret inside a sub-account Custom Value instead:
- Go to Settings > Custom Values in your sub-account.
- Click + Add Custom Value.
- Name it something clear like
Backend Webhook Secret. - Paste your token in the Value field (e.g.,
sk_live_9f83a8b417c2e0e9). - Click Save.
HighLevel exposes this as a merge tag: {{custom_values.backend_webhook_secret}}. Reference that tag in your workflow headers and your credentials stay centralized. If you want to see how this works across complex payload setups, check out our guide on Custom Values and Custom Fields in GoHighLevel Webhooks.
Configure the Custom Webhook Action in HighLevel
Once your token lives in Custom Values, set up the webhook step to pass it in the HTTP headers.
Here’s how to configure the action:
- Open your workflow under Automation > Workflows.
- Add a new action and pick Custom Webhook (or Webhook depending on your builder version).
- Set the method to
POST. - Drop in your destination endpoint (e.g.,
https://api.yourdomain.com/v1/ghl-receiver). - Scroll down to Headers and click Add Header.
Match the key and value structure to what your server expects:
- Bearer Token: Key =
Authorization| Value =Bearer {{custom_values.backend_webhook_secret}} - Direct API Key: Key =
X-API-Key| Value ={{custom_values.backend_webhook_secret}} - JSON Content: Key =
Content-Type| Value =application/json
If you’re running a multi-tenant backend, add an X-Location-ID header mapped to {{location.id}}. That lets your server route data immediately without having to unpack the JSON body first. See our quick guide on finding your sub-account Location ID if you need to double-check your ID format.
Write the Backend Receiver to Verify Headers
On your server, intercept the incoming headers and validate the auth token before wasting CPU cycles deserializing large contact records or querying databases.
Here is an Express.js middleware implementation validating both the Bearer token and an optional tenant ID:
const express = require('express');
const app = express();
app.use(express.json());
const EXPECTED_BEARER_TOKEN = process.env.GHL_WEBHOOK_SECRET || 'sk_live_9f83a8b417c2e0e9';
function authenticateGhlWebhook(req, res, next) { const authHeader = req.headers['authorization']; const locationId = req.headers['x-location-id']; if (!authHeader) { return res.status(401).json({ error: 'Missing Authorization header' }); } const parts = authHeader.split(' '); if (parts.length !== 2 || parts[0] !== 'Bearer') { return res.status(401).json({ error: 'Malformed Authorization header format' }); } const token = parts[1]; if (token !== EXPECTED_BEARER_TOKEN) { return res.status(403).json({ error: 'Invalid webhook authentication token' }); } req.ghlLocationId = locationId || null; next();
}
app.post('/v1/ghl-receiver', authenticateGhlWebhook, (req, res) => { const contactData = req.body; console.log(`Processing contact ${contactData.contact_id || 'unknown'} for location ${req.ghlLocationId}`); res.status(200).json({ received: true });
});
app.listen(3000, () => { console.log('Webhook receiver running on port 3000');
});If you’re using Python and FastAPI, here is the same validation pattern using header dependencies:
import os
from fastapi import FastAPI, Header, HTTPException, status
from pydantic import BaseModel
from typing import Optional, Dict, Any
app = FastAPI()
EXPECTED_SECRET = os.getenv("GHL_WEBHOOK_SECRET", "sk_live_9f83a8b417c2e0e9")
class WebhookPayload(BaseModel):
contact_id: Optional[str] = None
email: Optional[str] = None
customData: Optional[Dict[str,
Any]] = None
@app.post("/v1/ghl-receiver")
async def receive_ghl_webhook(
payload: WebhookPayload,
authorization: Optional[str] = Header(None),
x_location_id: Optional[str] = Header(None)
):
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Authorization header"
)
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or token != EXPECTED_SECRET:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid authentication credentials"
)
return { "status": "accepted", "location": x_location_id, "contact": payload.email
}Debugging Webhook Failures: What Broke When I Tested This
When I tested high-volume webhooks with custom headers in HighLevel, a few nasty edge cases tripped me up:
- Missing space in Bearer header: Typing
Bearer{{custom_values.token}}without a space afterBearersends a mangled header string. Your backend will fail to extract the token. Make sure there is exactly one space:Bearer {{custom_values.token}}. - 10-second timeout ceiling: HighLevel expects a
200 OKwithin roughly 10 seconds. If your receiver processes synchronous database writes, sends third-party transactional emails, or calls other APIs before returning a response, the workflow logs a timeout failure. Always return a200or202 Acceptedright away, then pass the payload to a worker queue. For high-volume setups, check our guide on handling webhook spikes with a Redis buffer queue in Node.js. - Header case sensitivity: HTTP/2 lowercases all incoming header keys. If your Node.js code checks
req.headers['Authorization']with a capital ‘A’, it’ll returnundefined. Stick to lowercase references likereq.headers['authorization'].
Static Keys vs Dynamic HMAC Signatures
Custom headers in HighLevel work reliably for static Bearer tokens and API keys. But if your security spec demands dynamic HMAC SHA-256 signatures generated from the raw body on the fly (like Stripe or GitHub webhooks do), HighLevel can’t do that natively.
There’s no cryptographic hashing function inside workflow action nodes. If you must have HMAC validation, stick a lightweight Cloudflare Worker or AWS Lambda function in front of your core API to sign and forward the payload.
Frequently Asked Questions
Can I use merge fields inside custom header values in HighLevel?
Yes. You can use Custom Values like {{custom_values.my_key}} alongside contact and location merge fields like {{location.id}} or {{contact.id}} directly inside the header value fields.
How many custom headers can I add to a single webhook action?
HighLevel doesn’t impose a strict low limit. In practice, most setups use two to four headers: Authorization, Content-Type, X-Location-ID, and an optional environment flag like X-Environment.
What happens if a Custom Value referenced in a header is empty?
If the Custom Value has no string set, HighLevel leaves that part blank. The header key will still send (e.g., Authorization: Bearer ), which causes your backend auth check to fail with a 401. Always make sure the value is populated before enabling the workflow.
Does HighLevel retry failed webhook deliveries?
Standard workflow actions won’t automatically retry 4xx errors like 401 Unauthorized or 404 Not Found. If your server returns a 5xx error, GHL may retry depending on platform stability settings. Check the Execution Logs tab in the workflow editor to see raw failure reasons.
Next Steps for Webhook Pipelines
Once your headers validate and return clean 200s in the workflow execution logs, test edge cases like empty payload fields and special characters. If your automations rely on custom contact properties, check out our guide on mapping form submission data to custom fields in GoHighLevel to keep your incoming data clean.

