When you drop a standard Webhook action into a GoHighLevel workflow, it fires off a massive, messy JSON dump. HighLevel sends every default contact property, weird internal system IDs, and arbitrary field structures your backend probably didn’t ask for. If your API expects a clean schema—or needs sub-account Custom Values mixed with Contact Custom Fields—the default webhook is a pain to work with.
Here’s how I structure custom POST payloads in HighLevel workflows, map dynamic merge tags cleanly, and handle edge cases like unquoted empty fields that silently break JSON parsing.

Default Webhook Payloads vs Custom Webhook Payloads
HighLevel’s basic Webhook action dumps the raw contact object on your endpoint. It includes timestamps, system tags, and an unstructured array of custom fields keyed by auto-generated internal hashes.
This causes two main headaches when connecting to external APIs:
- Schema Mismatches: External services (Stripe, Airtable, or your own Node/Python backend) usually want clean keys like
customer_idordeal_amount, not HighLevel’s internal IDs. - Missing Account Context: Location-level config—like internal API tokens, webhook secrets, or environment flags—won’t show up unless you explicitly inject them into the request.
If your webhook isn’t arriving at all, check our troubleshooting guide on how to fix GoHighLevel workflow webhooks not firing before tearing your payload apart.
Understanding Merge Tag Formats for Custom Values and Fields
HighLevel uses Liquid-style merge tags wrapped in double curly braces. The exact tag syntax depends on the data scope:
- Standard Contact Fields:
{{contact.first_name}},{{contact.email}},{{contact.phone}},{{contact.id}} - Contact Custom Fields:
{{contact.your_custom_field_name}}(or system keys like{{contact.custom_fields.lead_score}}) - Sub-Account Custom Values:
{{custom_values.api_key}},{{custom_values.brand_name}} - Workflow / Trigger Context:
{{workflow.name}},{{appointment.start_time}},{{order.total_amount}}
For standard field keys, check the official GoHighLevel API Documentation. To verify tag syntax before running live data, see our walkthrough on how to insert and test merge fields in HighLevel.
Building a Custom JSON Payload in Workflow Builder
To control the exact payload structure, use the Custom Webhook action (or the standard Webhook step with a custom data body).
Here is how to set it up:
- Go to Automation > Workflows and open your workflow.
- Add a new action and pick Webhook.
- Set the Method to
POST(orPUTdepending on your endpoint). - Add your destination endpoint URL.
- Switch the body format to Custom Data / JSON.
Here is a clean, production-ready payload template mixing static environment flags, sub-account custom values, and dynamic contact fields:
{ "event": "lead.created", "source": "gohighlevel_workflow", "auth": { "account_key": "{{custom_values.internal_service_key}}", "environment": "{{custom_values.app_env}}" }, "contact": { "ghl_id": "{{contact.id}}", "first_name": "{{contact.first_name}}", "last_name": "{{contact.last_name}}", "email": "{{contact.email}}", "phone": "{{contact.phone}}", "lead_score": {{contact.lead_score}}, "preferred_contact_method": "{{contact.preferred_channel}}", "onboarding_status": "{{contact.onboarding_status}}" }, "metadata": { "location_id": "{{location.id}}", "workflow_id": "{{workflow.id}}", "timestamp": "{{right_now}}" }
} Notice that string values are wrapped in quotes ("{{contact.first_name}}"), while numeric values like {{contact.lead_score}} can stay unquoted—provided the field is guaranteed never to be empty.
Passing Custom Values via HTTP Request Headers
Don’t dump secret tokens or tenant IDs into the body if your API expects them in headers. HighLevel lets you use dynamic merge tags directly inside the request headers table.
Set up your Headers table like this:
- Authorization:
Bearer {{custom_values.api_bearer_token}} - X-Agency-Location:
{{location.id}} - Content-Type:
application/json
If you need signature verification or custom auth tokens, read our guide on how to send authenticated webhooks with custom headers in GoHighLevel.
Handling Empty Fields and Fallback Logic
This is where most HighLevel webhooks quietly break: unquoted numeric fields with empty values. If lead_score is blank and your template has "score": {{contact.lead_score}}, HighLevel sends "score": , which is malformed JSON. Your receiver throws a 400 Bad Request and drops the record.
You can handle this in two ways:
1. Always quote numeric fields if your API can coerce types
If you control the receiving backend, wrap numbers in quotes and parse them on arrival. It’s much safer:
{ "lead_score": "{{contact.lead_score}}", "deal_amount": "{{contact.estimated_value}}"
}2. Configure fallback values
HighLevel supports default fallback values on merge tags. If the field is blank on the contact record, the fallback ensures your JSON stays structurally valid. See our guide on how to set fallback default values for merge fields in GoHighLevel for the exact syntax.
Inspecting and Testing the Webhook Output
Never guess what HighLevel is sending over the wire. Test against a live inspector tool like Webhook.site first.
Here’s what an actual rendered payload looks like when captured on the receiving end:
{ "event": "lead.created", "source": "gohighlevel_workflow", "auth": { "account_key": "sec_live_948274102948", "environment": "production" }, "contact": { "ghl_id": "92Ka81Lz091XbaYYq", "first_name": "Sarah", "last_name": "Connor", "email": "sarah@example.com", "phone": "+15550192831", "lead_score": "85", "preferred_contact_method": "SMS", "onboarding_status": "Pending Verification" }, "metadata": { "location_id": "kL8901Nma9912", "workflow_id": "wf_3819402941", "timestamp": "2025-02-18T14:22:10Z" }
}You can also check the workflow’s Execution Logs tab. Click into the Webhook step to see the exact payload HighLevel transmitted along with the response status code your server returned.
Receiving and Processing the Payload (Node.js Example)
Here’s a minimal Express handler showing how to grab, validate, and parse the custom payload on your server:
const express = require('express');
const app = express(); app.use(express.json()); app.post('/api/ghl-webhook', (req, res) => { const { auth, contact, metadata } = req.body; // Verify account secret passed from Custom Values if (!auth || auth.account_key !== process.env.GHL_WEBHOOK_SECRET) { return res.status(401).json({ error: 'Unauthorized payload' }); } const ghlContactId = contact.ghl_id; const email = contact.email; const leadScore = parseInt(contact.lead_score, 10) || 0; console.log(`Processing lead ${email} (ID: ${ghlContactId}) with score: ${leadScore}`); // Perform downstream database write or external sync here return res.status(200).json({ status: 'success', received_id: ghlContactId });
}); app.listen(3000, () => console.log('Webhook receiver running on port 3000')); Make sure your endpoint sends back a 200-204 status fast. HighLevel will time out and mark the step failed if your server hangs around waiting on long-running tasks.
Gotchas and Common Errors I Fixed
A few hard-learned lessons from debugging broken payloads in production:
- Unescaped Quotes in Free-Text Fields: If a contact writes
I said "hello"in a custom notes field, it will inject unescaped quotes right into your raw JSON template and break parsing. Sanitize text beforehand or stick to the standard key-value builder for user-generated text. - Strict Case Sensitivity: HighLevel merge tags are strictly case-sensitive.
{{Contact.email}}won’t resolve; it has to be{{contact.email}}. - Multi-Select Checkboxes Output as Strings: When passing checkbox fields with multiple selections, HighLevel outputs a comma-separated string, not a JSON array. You’ll need to
.split(',')it on your server. - Missing Custom Values After Snapshot Imports: Custom Values are sub-account specific. If you snapshot a workflow into a new location without creating matching Custom Value keys first, the tags resolve to empty strings without throwing an explicit error.
Frequently Asked Questions
Can I send files or attachments through GoHighLevel custom webhooks?
HighLevel file upload fields store CDN links, not binary data. Passing {{contact.contract_file}} in your payload sends the public URL hosted on HighLevel’s storage, which your backend can then download.
What is the execution timeout limit for HighLevel webhooks?
HighLevel expects an HTTP response within 10 seconds. If your processing takes longer, return an immediate 200 OK acknowledgement and handle the work asynchronously with a background queue or worker.
How do I send custom fields from an Opportunity instead of a Contact?
If your workflow triggers on an opportunity event (like Opportunity Status Changed or Pipeline Stage Changed), you can use tags like {{opportunity.name}}, {{opportunity.monetary_value}}, and {{opportunity.pipeline_stage}} alongside standard contact fields in your payload.
Why did my webhook send raw curly braces instead of the actual data?
This happens when there’s a typo in the merge tag name, the custom field was deleted from sub-account settings, or the trigger context doesn’t contain that object (e.g., trying to use {{appointment.start_time}} on a tag-added trigger that has no appointment data).

