← All posts

GoHighLevel Custom Webhook: Send Workflow Data to External APIs

Hamza Lamhidra
GoHighLevel Custom Webhook for API requests, authentication, JSON payloads, and troubleshooting.

Learn how to use GoHighLevel Custom Webhooks to call external APIs with custom authentication, headers, JSON payloads, response handling, and debugging.

GoHighLevel's Custom Webhook action lets a workflow make a configurable HTTP request to an external API.

It currently supports GET, POST, PUT, and DELETE, along with authentication, headers, query parameters, dynamic workflow values, JSON or form payloads, and optional response capture.

The setup itself is not the difficult part.

The important question is:

What exact request does the receiving API expect?

A reliable Custom Webhook starts with that contract:

External API documentation
        ↓
Endpoint
        ↓
HTTP method
        ↓
Authentication
        ↓
Headers
        ↓
Content-Type
        ↓
Request body
        ↓
Expected response
        ↓
GoHighLevel Custom Webhook

If the receiving API expects a Bearer token, a specific JSON schema, and particular headers, the Custom Webhook needs to reproduce that contract.

If the receiving endpoint only needs a simple workflow payload, the standard Webhook action may already be enough.

That distinction is the best place to start.

GoHighLevel Webhook vs Custom Webhook: Which One Should You Use?

HighLevel has both a standard Webhook workflow action and a Custom Webhook action.

They overlap, but they are not the same tool.

The standard outbound Webhook is designed to send workflow and record context to an external URL. HighLevel includes standard contact data and can add trigger-dependent context and custom key/value data.

Custom Webhook gives you substantially more control over the HTTP request itself, including authentication, headers, query parameters, content type, raw JSON, and response handling.

A useful decision table is:

Requirement

Standard Webhook

Custom Webhook

Send workflow data to an external URL

Yes

Yes

Send standard contact/trigger context

Strong fit

Can map selected values

Add simple custom key/value data

Yes

Yes

Build a specific JSON contract

Limited

Strong fit

Bearer token authentication

Not its main purpose

Yes

API key / Basic Auth / OAuth2

Not its main purpose

Yes

Custom headers

Limited compared with Custom

Yes

Query parameters

Limited compared with Custom

Yes

PUT / DELETE and advanced request control

Limited

Yes

Save/use an API response

Not the main pattern

Available in Custom Webhook where enabled

The rule is simple:

Use Custom Webhook when the receiving API requires a request contract that the standard Webhook does not give you enough control to build.

Do not choose Custom Webhook only because it sounds more advanced.

If your destination is simply an n8n Webhook node waiting for a basic workflow payload, the standard Webhook may be the simpler solution.

But if an external API says:

POST /leads

Authorization: Bearer <token>
Content-Type: application/json

and expects:

{
  "firstName": "John",
  "lastName": "Smith",
  "email": "john@example.com"
}

Custom Webhook is a much more natural fit.

To understand how Custom Webhook differs from HighLevel's other webhook surfaces, see GoHighLevel Webhooks

A HighLevel workflow needed to hand a lead off to Ultravox to start an AI voice call. Ultravox authenticates with a custom header, X-API-Key, and its call-creation endpoint expects a specific JSON body, not HighLevel's default payload. The standard Webhook action couldn't build that request: it sends a predefined workflow payload and doesn't give you control over custom authentication headers or the exact JSON schema an API like Ultravox requires. So I used the Custom Webhook action, which let me set the method, add the X-API-Key header through credential storage, and map the workflow's runtime values into the JSON structure Ultravox expects.

The distinction is worth stating plainly: if the destination had just been an n8n catch webhook waiting for a basic payload, the standard Webhook action would have been enough, because there's no custom auth header and no strict body contract to satisfy. Custom Webhook earned its place here specifically because Ultravox's request contract, a required auth header and a defined JSON schema, was something the standard action couldn't reproduce.

Start With the External API Contract, Not the HighLevel UI

A common mistake is opening Custom Webhook first and asking:

“Which HighLevel fields can I send?”

Start from the other direction.

Open the receiving API documentation and determine exactly what it expects.

For example:

Endpoint:
POST /v1/customers

Authentication:
Authorization: Bearer <token>

Content-Type:
application/json

Required fields:
firstName
lastName
email

Optional:
crmId
source

Only then should you configure HighLevel.

The resulting mapping becomes:

External API requires
        ↓
firstName
lastName
email
crmId
        ↓
Map HighLevel runtime values
        ↓
Custom Webhook request

For example:

{
  "firstName": "{{contact.first_name}}",
  "lastName": "{{contact.last_name}}",
  "email": "{{contact.email}}",
  "crmId": "{{contact.id}}"
}

The important principle is:

Build the request the receiving API expects, rather than sending whatever HighLevel data happens to be easiest to select.

An API contract can care about more than whether a value exists.

It can care about:

For example:

{
  "amount": 99
}

is not necessarily equivalent to:

{
  "amount": "99"
}

One is a number.

The other is a string.

Similarly:

{
  "firstName": "John",
  "lastName": "Smith"
}

is not the same contract as:

{
  "fullName": "John Smith"
}

even though the human-readable information appears similar.

When an API returns 400 or 422, this contract is one of the first things to inspect.

How to Configure a GoHighLevel Custom Webhook

The current HighLevel Custom Webhook action gives you both simplified and advanced configuration modes.

The exact interface can change, but the setup logic remains the same.

1. Add the Custom Webhook action

Open the workflow where the external API request should happen.

Add:

Custom Webhook

This is an outbound action:

HighLevel Workflow
        ↓
Custom Webhook
        ↓
External API

The trigger that started the workflow can vary. HighLevel's current documentation says Custom Webhook does not require one specific trigger, but the dynamic values you map must exist in that workflow's runtime context.

2. Choose the correct configuration mode

Current HighLevel documentation distinguishes between the Event selector and the actual HTTP method.

The simplified GET and POST modes expose simpler configuration.

The CUSTOM mode exposes the advanced request builder, including:

This matters when someone asks:

“Why can't I find the JSON/raw body editor?”

Check the Event selection first.

If the external API requires explicit JSON, PUT, DELETE, or more advanced control, the current documentation directs users toward the CUSTOM configuration mode.

3. Match the HTTP method

Custom Webhook currently supports:

GET
POST
PUT
DELETE

Typical API conventions are:

GET
→ retrieve information

POST
→ create/send data

PUT
→ update/replace a resource

DELETE
→ remove a resource

But those are conventions, not instructions for your specific integration.

Always use the method documented by the receiving API.

If its documentation says:

PUT /customers/{id}

do not change it to POST because POST feels more familiar.

4. Enter the correct endpoint

The endpoint might be static:

https://api.example.com/v1/leads

or include a runtime identifier:

https://api.example.com/v1/customers/{{contact.id}}

HighLevel's current Custom Webhook documentation supports dynamic values in URL paths as well as headers, parameters, and bodies.

5. Configure authentication

Match the receiving API's documented authentication mechanism.

Do not guess.

6. Add required headers and query parameters

Examples might include:

Content-Type: application/json

or:

X-API-Key: ...

or query parameters such as:

?status=active

HighLevel supports headers and query parameters in the Custom Webhook action.

7. Build the request body

For JSON APIs, map the HighLevel runtime values into the exact schema the external service expects.

Current HighLevel documentation supports both nested JSON structures and arrays.

8. Test the request

Test before publishing.

Inspect:

Request
+
External API response
+
HighLevel Execution Logs

HighLevel recommends checking the URL, method, Content-Type, headers, credentials, payload shape, and Execution Logs during testing.

9. Test again with realistic workflow data

A manually constructed test is not enough if the production workflow will run with different contacts, opportunities, custom objects, or webhook values.

Verify that the variables you mapped actually exist in the runtime context that will trigger the production workflow.

Authentication: Match What the External API Requires

Custom Webhook currently supports several authentication options:

The important distinction is the direction of authentication.

Here:

HighLevel Workflow
        ↓
authenticates TO
        ↓
External API

This is different from authenticating your application into the HighLevel API.

Bearer token

An API may require:

Authorization: Bearer <token>

Select the corresponding authentication method and use the credential supplied by that provider.

API key

Some APIs expect:

X-API-Key: <secret>

HighLevel's current documentation recommends using headers for API keys when supported and using a query-string API key only when the provider specifically requires it.

Basic Auth

If the provider expects username/password-based Basic authentication, Custom Webhook supports that model as well.

OAuth2

Custom Webhook also supports OAuth2 connections to external services.

HighLevel documents OAuth tokens under Global Workflow Settings → OAuth2 / Manage Tokens, after which the configured token can be selected by the Custom Webhook action.

Again, this is outbound authentication to another API.

It is not the same decision as choosing OAuth vs a Private Integration Token when building against HighLevel's own API.

Store API Secrets as Credentials, Not Business Data

API credentials should be treated differently from the business data in the webhook payload.

HighLevel provides secure credential management for Custom Webhook authentication.

Current credential management supports masked secrets for:

Stored keys are masked in the interface, are location-scoped, and are managed by their creator or authorized agency administrators.

So avoid doing this merely because it is convenient:

{
  "email": "customer@example.com",
  "apiSecret": "sk_live_..."
}

Credentials do not normally belong inside the business payload unless the receiving API explicitly defines that contract.

Similarly, avoid:

https://api.example.com/leads?api_key=SECRET

unless the provider specifically requires query-string authentication.

Keeping credentials in the correct authentication layer helps with:

import { FastifyInstance } from 'fastify';
import { timingSafeEqual } from 'crypto';

const WEBHOOK_SECRET = process.env.INBOUND_WEBHOOK_SECRET;

export async function webhookRoutes(app: FastifyInstance) {
  if (!WEBHOOK_SECRET) {
    throw new Error('INBOUND_WEBHOOK_SECRET is not set');
  }

  // GoHighLevel Inbound Webhook triggers are not signed, so there is no
  // signature to verify. Instead we require a shared secret on every request
  // and reject anything without it, before any workflow is started.
  app.addHook('preHandler', async (request, reply) => {
    const provided = request.headers['x-webhook-secret'];
    if (typeof provided !== 'string') {
      return reply.code(401).send({ error: 'missing secret' });
    }
    const a = Buffer.from(provided);
    const b = Buffer.from(WEBHOOK_SECRET);
    // Constant-time compare; guard the length first so it can't throw.
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return reply.code(401).send({ error: 'invalid secret' });
    }
  });

  app.post<{ Params: { workflowType: string } }>(
    '/:workflowType',
    async (request, reply) => {
      // Request is authenticated here — safe to dispatch.
      // (validate payload, dedupe, start the workflow, respond)
      return reply.code(202).send({ status: 'accepted' });
    }
  );
}

Build the JSON Body the Receiving API Expects

Payload mapping is where many Custom Webhook integrations succeed or fail.

Suppose HighLevel currently has:

First Name: John
Last Name: Smith
Email: john@example.com
Contact ID: abc123

but the external API expects:

{
  "firstName": "John",
  "lastName": "Smith",
  "email": "john@example.com",
  "crmId": "abc123"
}

The purpose of Custom Webhook is not to send HighLevel's internal structure unchanged.

It is to construct the structure required by the destination.

Conceptually:

HighLevel runtime value
        ↓
mapping
        ↓
external API field

The final request might therefore be:

{
  "firstName": "{{contact.first_name}}",
  "lastName": "{{contact.last_name}}",
  "email": "{{contact.email}}",
  "crmId": "{{contact.id}}"
}

Nested JSON

An API may instead require:

{
  "customer": {
    "email": "john@example.com",
    "firstName": "John"
  },
  "source": {
    "platform": "highlevel"
  }
}

Current Custom Webhook documentation supports nested JSON structures.

Arrays

A destination may expect:

{
  "items": [
    {
      "sku": "PRODUCT_A",
      "quantity": 1
    },
    {
      "sku": "PRODUCT_B",
      "quantity": 2
    }
  ]
}

Current HighLevel documentation also supports arrays in Custom Webhook payloads.

The fact that HighLevel can construct the JSON does not mean every external schema belongs directly in Workflow Builder.

If the transformation becomes difficult to understand or maintain, middleware may become a better boundary.

But do not add middleware before you know you need it.

Dynamic Values Must Actually Exist When the Workflow Runs

Custom Webhook can map dynamic values from workflow context.

But a template such as:

{{contact.email}}

only works when the workflow actually has the corresponding contact context.

Likewise, an opportunity-specific value requires the relevant opportunity context.

A custom-object workflow can use data available from that object.

HighLevel currently supports Custom Webhook as an action in Custom Object workflows as well.

A useful mental model is:

Workflow starts
      ↓
What runtime context exists?
      ↓
Contact?
Opportunity?
Custom Object?
Inbound data?
Other workflow values?
      ↓
Map only available values

Do not assume that selecting a variable in the builder guarantees that a value will exist for every production execution.

HighLevel's current Custom Webhook FAQ explicitly advises that mapped variables need to exist at runtime.

Save the API Response and Use It Later in the Workflow

A Custom Webhook does not always need to be fire-and-forget.

HighLevel supports an optional Save response from this Webhook capability where available in the account.

A test request can establish the response structure, and saved response values can then be used as custom variables and in later workflow conditions.

For example, imagine an external lead-scoring API returns:

{
  "score": 87,
  "tier": "qualified",
  "externalId": "lead_938"
}

The workflow can conceptually become:

Custom Webhook
      ↓
Scoring API
      ↓
Response
      ↓
score = 87
      ↓
If / Else
      ↓
Qualified path

Or it could store:

externalId = lead_938

for later use.

This changes Custom Webhook from:

Send data outside HighLevel

into:

Send request
      ↓
receive immediate response
      ↓
continue workflow using result

HTTP success and business success are not always the same

Suppose the API returns:

200 OK

with:

{
  "success": false,
  "reason": "duplicate_customer"
}

From an HTTP perspective, the server successfully handled the request.

From the business-process perspective, you still need to interpret the response.

That means a useful integration may care about both:

HTTP status
      +
response body

rather than treating any 2xx response as proof that the intended business outcome occurred.

Immediate API Response vs Long-Running External Process

Response capture works naturally when the external API completes its work quickly enough to return the result in the same request.

For example:

HighLevel Workflow
        ↓
Custom Webhook
        ↓
Lead-scoring API
        ↓
Score returned
        ↓
Continue workflow

That is a synchronous request/response pattern.

But some APIs only start a job.

For example:

HighLevel
   ↓
Custom Webhook
   ↓
Start document-generation job
   ↓
Response:
{
  "jobId": "job_123",
  "status": "processing"
}

The final document may not exist yet.

In that case, the response tells you that the job started—not that the business process finished.

A different integration boundary may be more appropriate:

HighLevel Custom Webhook
        ↓
Start external job
        ↓
External service processes
        ↓
Completion callback
        ↓
HighLevel Inbound Webhook
        ↓
Continue workflow

If an external system needs to send data back into a workflow, see GoHighLevel Inbound Webhook

The important distinction is:

Use immediate response handling for immediate API results. Design an explicit callback or status-checking process when the external work completes asynchronously.

Do not keep adding waiting assumptions around a request whose destination was designed to return later.

How to Debug a Failed GoHighLevel Custom Webhook

When a Custom Webhook fails, separate HighLevel workflow execution from the external API response.

Use this sequence:

Did the workflow reach the action?
        ↓
Was the request constructed correctly?
        ↓
Did the external API receive it?
        ↓
What HTTP status came back?
        ↓
What did the response body say?

HighLevel's current documentation recommends inspecting its Execution Logs together with the provider's response/logs.

A useful first-pass diagnostic table is:

Response

First area to inspect

400 / 422

Request body, required fields, types, JSON structure

401 / 403

Authentication, token, permissions, OAuth scopes

404

Endpoint, URL path, resource ID

409

Conflict or duplicate resource state

429

Receiving API rate limit

5xx

External provider/server failure

400 or 422: inspect the payload

Typical questions:

Is Content-Type correct?
Are required fields present?
Are names spelled exactly as documented?
Is a number being sent as a string?
Is nesting correct?
Is a required value empty?

HighLevel specifically recommends checking required fields, data types, nested structure, and Content-Type for 400/422 responses.

401 or 403: inspect authentication

Check:

HighLevel identifies these as common causes of 401/403.

Do not keep modifying the JSON body if the destination is rejecting your credentials.

404: inspect the endpoint and IDs

A request can be perfectly authenticated and still call:

/v1/customers/wrong-id

or an outdated API route.

Check:

429: the receiving service is limiting you

A 429 from the external API does not automatically mean HighLevel itself is rate limiting the workflow.

Identify which system returned the response.

Then respect that provider's API limits.

5xx: the destination may be failing

A 500, 502, 503, or similar response usually shifts investigation toward the external server or gateway.

That still does not mean a retry is automatically safe.

We need one more question first:

Did the external business operation already happen?

A Custom Webhook calling Ultravox returned 401 Unauthorized. The workflow executed and the request was sent, so HighLevel's side looked fine; the failure was in the external API's response. Reading the response, it was an authentication rejection, not a payload problem.

What I inspected was the authentication configuration, not the JSON body. The mistake was the auth mechanism: Ultravox authenticates with a custom X-API-Keyheader, not a Bearer token in the Authorization header. The Custom Webhook was sending the key the wrong way, so Ultravox rejected it before the body ever mattered.

The fix was to configure the key as the X-API-Key header (via credential storage) instead of Bearer auth. Once the key arrived in the header Ultravox expected, the request authenticated and the call was created. The lesson that generalizes: a 401 is an authentication-layer problem, so inspect the auth method and header first, and don't start rewriting the JSON body when the destination is rejecting your credentials.

Retrying a Failed Custom Webhook Can Create Duplicates

A failed-looking request is not always equivalent to a business operation that never happened.

Imagine:

Custom Webhook
      ↓
External API
      ↓
Create customer ✅
      ↓
Response becomes unavailable / later step fails
      ↓
Workflow retried
      ↓
Create customer again?

The danger is the difference between:

HTTP delivery uncertainty

and:

business-operation state

Before retrying operations such as:

understand what the receiving API offers for duplicate protection.

Useful mechanisms can include:

The exact solution depends on the API.

The key rule for this page is:

A retry mechanism is only safe when repeating the underlying business operation is safe—or when duplicate execution is prevented or reconciled.

For the deeper partial-failure and idempotency architecture:

If the request hands off to a custom application, use the reliable GoHighLevel API integration architecture

Worth being honest about a real limit in this kind of setup. When a Custom Webhook (or, in a backend, an activity) calls an external API that places a phone call or sends an SMS, a blind retry can duplicate the side effect, and that duplicate is a second real call or text to a person, not just a duplicate record. It carries cost and compliance exposure that a duplicate CRM write does not.

The danger is the gap between "the request failed" and "the operation did not happen." A client that times out after the provider already accepted the request, a 5xx returned after the provider already acted, or a worker that dies after the POST succeeds but before the result is recorded all look like failures from the caller's side, and a naive retry sends the message again.

Preventing that requires something to make the operation identifiable across attempts. The strongest option is an idempotency key the provider honors, so a repeat resolves to the original instead of creating a new send. When the provider doesn't offer one, the fallbacks are a stable external identifier stored from the first response, a lookup for an existing record before sending again, or reconciliation against the provider's state. The specific mechanism depends entirely on what the API supports, which is why the first step is checking the provider's documentation for idempotency support before designing the retry.

The honest rule: retrying is only safe when repeating the underlying operation is safe, or when a duplicate is prevented or reconciled. For side effects that reach a real person, that safety has to be designed in, not assumed.

When a Custom Webhook Is Enough Without n8n or a Backend

Not every external API connection requires middleware.

Consider:

HighLevel Workflow
      ↓
Custom Webhook
      ↓
External API

That may be the best architecture when:

For example, suppose the only requirement is:

When lead reaches stage X
      ↓
POST four fields to external service
      ↓
Bearer token
      ↓
Receive success/failure

Adding:

HighLevel
 ↓
n8n
 ↓
transform four fields
 ↓
external API

may provide no additional value.

The middle layer should have a job.

Do not add middleware only to reshape a request that Custom Webhook can already build clearly and securely.

Custom Webhook itself can sometimes eliminate an otherwise unnecessary transformation layer.

Custom Webhook is flexible, but it can't own a long-running process. A real example: a set of GoHighLevel workflows drove outbound voice calls and SMS sequences directly through Custom Webhook actions, calling the external providers straight from the workflow. That worked as a starting point, but the process needed things a fire-and-forget webhook can't provide: durable waits for each lead's business-hours window, multi-day sequences, bounded retries per side effect, per-location rate limiting, and deduplication so a repeated trigger doesn't repeat the work. Those requirements moved the orchestration into a backend with a durable workflow engine, while the provider calls still happened over HTTP.

One concrete thing broke in the move and is worth naming. In GoHighLevel, a caller-ID cursor was serialized naturally by the workflow's Drip steps, so only one call advanced the cursor at a time. Moving to parallel workflows removed that implicit ordering and the serialization broke, until a dedicated single-slot queue was added to make that one operation run serially again. That is the kind of guarantee an automation layer gives you for free and a parallel system has to recreate on purpose, and it is a good marker of when the process has outgrown direct webhooks.

When You Still Need n8n or a Custom Backend

Custom Webhook is flexible, but it is not a replacement for every integration layer.

Middleware makes sense when it owns an actual responsibility.

Examples include:

Fixed outbound IP requirement

One concrete example is IP allowlisting.

HighLevel's current Custom Webhook documentation says it does not provide a static outbound IP for a receiving provider to allowlist.

If an enterprise API requires:

Only requests from:
203.0.113.x

a direct Custom Webhook may not satisfy that security policy.

An architecture could instead be:

HighLevel Custom Webhook
        ↓
Your gateway/backend
        ↓
Fixed egress IP
        ↓
Enterprise API

Now the backend has a clear responsibility.

Complex transformation

Similarly:

HighLevel
      ↓
20 fields
      ↓
combine 3 external datasets
      ↓
calculate business rules
      ↓
produce another schema

may be easier to maintain in n8n or application code than inside one large Custom Webhook request.

The architecture rule remains:

Choose the smallest layer that owns the responsibility cleanly.

Does GoHighLevel Custom Webhook Cost Money?

Yes, Custom Outbound Webhook is currently classified as a Premium Workflow action.

HighLevel's pricing guide, most recently updated in August 2026, currently lists Workflow Premium Features at:

$0.01 per execution under pay-as-you-go pricing.

HighLevel also offers Workflow Pro tiers for larger Premium Workflow volumes.

Pricing can change, so check the current HighLevel billing documentation or in-app billing settings before estimating client or production costs.

For most integrations, price should be considered alongside:

Do not choose a worse architecture only to optimize a tiny per-execution difference without considering the full system.

GoHighLevel Custom Webhook Production Checklist

Before publishing a workflow that calls an external API through Custom Webhook, confirm:

A Custom Webhook is production-ready when those boundaries are understood—not simply when one test request returns 200.

Need Help With a GoHighLevel Custom Webhook Integration?

The basic Custom Webhook action is quick to configure.

The difficult part is usually the integration contract around it:

If your workflow has reached that point, Hamza can review the request and the surrounding integration architecture.

The simplest correct answer may be a better Custom Webhook configuration.

It may be n8n.

It may require a custom backend.

The architecture should be chosen based on what the external API and business process actually require.

Need a custom outbound integration? Work with a GoHighLevel developer for webhook integrations

Work with me on Upwork

Book a free workflow audit

30 minutes. We look at your two or three most critical workflows and I tell you which ones are actually at risk. No pitch deck.