WP Webhooks / Blog / Integrations

Sending WordPress Leads to GoHighLevel With an Inbound Webhook or API v2

WordPress to GoHighLevel: the Inbound Webhook trigger and what it costs, Private Integration tokens, contact upsert that wipes tags, and opportunities.

9 min 2026-10-07
#gohighlevel#crm#api

TL;DR: There are two ways to get WordPress data into GoHighLevel, and they fail in different places.

  • Inbound Webhook trigger: a workflow URL that takes JSON with no authentication at all. Quick to set up, a premium trigger at $0.01 per execution after the first 100, and anyone who has the URL can call it.
  • API v2 with a Private Integration token: Authorization: Bearer … plus a Version header against services.leadconnectorhq.com. POST /contacts/upsert creates or updates, and POST /opportunities/ needs the contactId the upsert returned.
  • An upsert that sends tags replaces every tag the contact already has. Add tags with their own endpoint.
  • The official LeadConnector plugin embeds HighLevel's forms and chat. It does not send Contact Form 7, Gravity Forms or WooCommerce data anywhere.

/ Official plugin

Does the official GoHighLevel WordPress plugin send form entries?

No. HighLevel's plugin is published as LeadConnector and has 20,000+ active installs.¹ It brings HighLevel into WordPress: the chat widget, shortcodes that embed HighLevel's own forms, surveys and calendars, funnel pages imported into the site, and email sent through LeadConnector's SMTP. Its readme states that it embeds iframes and scripts directly and does not pass visitor input through PHP.

That is a good answer if you are willing to replace your forms with HighLevel's. It is no answer at all if the leads come from a Contact Form 7 or Gravity Forms form that already works, or from WooCommerce orders.

/ Two routes

Inbound Webhook trigger or API v2: which one should WordPress call?

Use the Inbound Webhook when the data should start a workflow and a person in the agency builds what happens next in the workflow editor. Use the API when WordPress needs a specific result: a contact with exact fields, an opportunity in a named pipeline stage, tags added rather than replaced.

Inbound Webhook triggerAPI v2 + Private Integration token
AuthenticationNone. The URL is the secretBearer token with chosen scopes
Cost100 free executions per sub-account, then $0.01 eachNo per-call fee
What it createsWhatever the workflow does with the dataExactly the record you send
Body formatJSON only, one-word keysJSON, defined per endpoint
Changing the payloadRe-select the mapping referenceChange the request

/ Inbound Webhook

How does the GoHighLevel Inbound Webhook trigger work?

You add the Inbound Webhook trigger to a workflow and HighLevel generates a URL for it. The URL accepts POST, GET and PUT. JSON is the only supported format, and keys should be single words, in camelCase or snake_case, with no spaces.

The setup has one step that trips people up: the trigger learns your payload from a real request. Send one test submission from WordPress, click Test Trigger, and pick that request as the mapping reference. Only then can the workflow's later actions, such as Create Contact, refer to email or firstName. Contact actions need an email or a phone number. If you add a field to the form later, the old reference does not contain it, so you send a new test and select the mapping reference again.

It is a premium trigger. Per HighLevel's premium features guide, each sub-account gets 100 free executions in total, not per month, and after that every execution costs $0.01, unless the agency has a Workflow Pro plan. Worked through for a form with 1,000 submissions a month:

  • First month: (1,000 − 100) × $0.01 = $9.00. Every month after that: 1,000 × $0.01 = $10.00, because the 100 free executions are used once.
  • On the Starter Workflow Pro plan at $10 a month, 10,000 executions are included, so the same form costs $10 whether it gets 1,000 submissions or 9,000.

Gotcha: the Inbound Webhook checks nothing. No secret, no signature. Anyone who finds the URL in a page source, a browser extension's log or a shared screenshot can create contacts in the sub-account and spend executions at $0.01 each. HighLevel's advice for a leaked URL is to delete the trigger and add a new one. So call it from the server, never from browser JavaScript.

/ Auth

How do you authenticate to the GoHighLevel API from WordPress?

With a Private Integration token. In the sub-account, open Settings, then Private Integrations, create an integration and choose its scopes. For this article that is contacts.write and opportunities.write. The token is shown once. Copy it straight into wherever it will live.

Each request carries two headers: Authorization: Bearer plus the token, and Version: 2021-07-28. The Version header is not optional. HighLevel's versioning page now also lists a named version, v3, released 11 June 2026, with every older version still supported and no retirement date set. The examples here pin 2021-07-28, the version the token documentation uses. Whichever you pin, note it somewhere other than the code.

A Private Integration token is static. It does not expire on its own, and HighLevel recommends rotating it every 90 days. Rotation has a gentle option: Rotate and expire this token later keeps the old and new token working side by side for seven days, which is the window you have to update WordPress. The old v1 API keys reached end of support on 31 December 2025, so a tutorial that shows rest.gohighlevel.com/v1 is out of date.

/ Contacts

Which GoHighLevel endpoint creates or updates a contact?

POST /contacts/upsert. The only required field is locationId, the sub-account. It returns 200 with new set to true or false and the full contact, including its id.

How it decides that a contact already exists is not in your request. It follows the sub-account's Allow Duplicate Contact setting, which says whether to match on email, phone, or both, and in which order. If the email matches one contact and the phone matches a different one, the upsert updates the contact matched by the first field in that order and ignores the second. Check that setting before you trust the upsert, because two sub-accounts with different settings will treat the same request differently.

JSON — POST https://services.leadconnectorhq.com/contacts/upsert

{
  "locationId": "YOUR_LOCATION_ID",
  "email": "[email protected]",
  "firstName": "Anna",
  "lastName": "Kowalska",
  "phone": "+48600100200",
  "source": "WordPress contact form",
  "customFields": [
    { "id": "YOUR_CUSTOM_FIELD_ID", "field_value": "Website redesign" }
  ]
}

Leave tags out of the upsert. The upsert reference is blunt about it: tags sent here overwrite every tag the contact already has. A returning lead who was tagged customer by your sales team loses that tag the next time they fill in a form. Add tags with POST /contacts/{contactId}/tags, which adds to the list instead of replacing it.

Cyberpunk illustration of a man clinging to the top of a rain-lashed signal mast above a night city, one fresh pennant just clipped onto the line beside him while every other pennant on it has been torn away, leaving only empty clips and snapped ties.

Custom fields go in a customFields array of objects. Each has the field's id and a field_value, which is a string, or an array for a checkbox field. The id comes from the sub-account's custom field settings or the custom fields endpoint; the visible field label will not work.

/ Opportunities

How do you create a GoHighLevel opportunity for a new lead?

With a second request, after the first one succeeds. POST /opportunities/ requires pipelineId, locationId, name, status and contactId. pipelineStageId is optional, but without it the opportunity lands wherever the pipeline puts it by default, so send it. status is open for a new lead.

The contactId is the reason this is two requests and not one: it only exists once the upsert has answered. That makes the order fixed. Create or update the contact, read contact.id from the response, then create the opportunity. If you only want a deal for first-time leads, check the new flag in the upsert response before the second call.

FIG 01 — Upsert first, then the opportunity with the contactId from the response; tags go through their own endpoint

PHP — upsert the contact, then open an opportunity

function ghl_request( $path, array $body ) {
    $res = wp_remote_post( 'https://services.leadconnectorhq.com' . $path, [
        'timeout' => 10,
        'headers' => [
            'Authorization' => 'Bearer ' . GHL_PRIVATE_TOKEN,
            'Version'       => '2021-07-28',
            'Content-Type'  => 'application/json',
            'Accept'        => 'application/json',
        ],
        'body'    => wp_json_encode( $body ),
    ] );
    if ( is_wp_error( $res ) ) {
        return $res;
    }
    $code = wp_remote_retrieve_response_code( $res );
    $data = json_decode( wp_remote_retrieve_body( $res ), true );
    if ( $code < 200 || $code >= 300 ) {
        return new WP_Error( 'ghl_' . $code, is_array( $data['message'] ?? null )
            ? implode( '; ', $data['message'] )     // 422: message is an array
            : (string) ( $data['message'] ?? 'HTTP ' . $code ) );
    }
    return $data;
}

function ghl_send_lead( array $lead ) {
    $upsert = ghl_request( '/contacts/upsert', [
        'locationId' => GHL_LOCATION_ID,
        'email'      => $lead['email'],
        'firstName'  => $lead['first_name'],
        'phone'      => $lead['phone'],
        'source'     => 'WordPress contact form',
    ] );
    if ( is_wp_error( $upsert ) ) {
        return $upsert; // in production: back onto the queue
    }

    if ( ! empty( $upsert['new'] ) ) {
        return ghl_request( '/opportunities/', [
            'locationId'      => GHL_LOCATION_ID,
            'pipelineId'      => GHL_PIPELINE_ID,
            'pipelineStageId' => GHL_STAGE_NEW_LEAD,
            'name'            => $lead['first_name'] . ' — website enquiry',
            'status'          => 'open',
            'contactId'       => $upsert['contact']['id'],
        ] );
    }
    return $upsert;
}

Note the error branch. A 422 from HighLevel carries message as an array of validation strings, while a 400 or 401 carries a single string, per the shared error schemas.² Code that casts message to a string logs Array for exactly the error you most need to read.

/ Limits

What are the GoHighLevel API rate limits?

Two of them, per the rate limits page: a burst limit of 100 requests per 10 seconds and a daily limit of 200,000 requests, counted per app per sub-account. The page words them for OAuth apps and does not say how a Private Integration token is counted, so read the headers rather than assuming: X-RateLimit-Remaining and X-RateLimit-Max for the burst window, X-RateLimit-Daily-Remaining for the day.

Checked at form volume: one lead costs up to three requests (upsert, tags, opportunity). 100 ÷ 10 = 10 requests a second, which is 10 ÷ 3 = about 3 leads a second in a burst. A daily cap of 200,000 ÷ 3 = 66,666 leads a day. An import of 20,000 old entries does: at 3 requests each that is 60,000 requests, and at 10 per second 60,000 ÷ 10 = 6,000 seconds, 100 minutes of steady sending. Run it as a queue that respects the headers, not as a loop.

ConcernHand-rolled wp_remote_postWebhook Actions
Running HighLevel workflows, calendars, chatNot this. That is HighLevel's jobNot this either. It sends WordPress events out; there is no HighLevel connector, you configure the request yourself
Which eventsOne add_action per form plugin, written by youAny WordPress action as a trigger: Contact Form 7, Gravity Forms, WooCommerce orders, user registration
The Private Integration tokenA constant in wp-config.phpA Bearer credential in the Credentials Vault, redacted in every log
The Version headerWritten in your arrayA static custom request header
locationId and other constantsWritten in your arrayA short pre-dispatch Code Glue snippet; field mapping moves values but cannot invent a constant
Opportunity after the upsertA second request in the same functionA chained webhook that fires on the upsert's 2xx and maps args.0.response.body.contact.id to contactId
A 429 or 5xxLost unless you wrote retry logicRetried with exponential backoff, five attempts by default. The rate-limit headers are not read
A 422 from a bad fieldInvisibleMarked permanently failed, HighLevel's message array in the delivery log, replayable after the fix

The pre-dispatch snippet for the upsert is four lines. Field mapping has already turned the form fields into email, firstName and phone; the snippet adds what no form field contains:

PHP — pre-dispatch Code Glue snippet on the upsert webhook

// $payload arrives already field-mapped: email, firstName, lastName, phone.
$payload['locationId'] = 'YOUR_LOCATION_ID';     // the sub-account
$payload['source']     = 'WordPress contact form';
return $payload;
try_it

Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.

/ Queue

Should the GoHighLevel call run inside the form submission?

No. A form submission waiting on a CRM has two bad outcomes: the visitor waits for HighLevel, and a HighLevel timeout looks to them like a failed form, although WordPress already saved the entry. Record the lead when the form plugin's completion hook fires, return the thank-you page, and let a background worker make the requests.

The upsert makes retries safe for the contact. The opportunity is the part that is not: POST /opportunities/ has no idempotency key, so a retried request after a timeout, where HighLevel did create the record but the response never arrived, creates a second deal. Gating it on new from the upsert helps, because the retried upsert answers new: false. Retry 429 and 5xx with backoff. A 401 is a revoked or rotated token and a 422 is a field HighLevel will reject the same way forever, so retrying those changes nothing. The retry policy article covers the schedule.

/ Exposure

What does GoHighLevel not protect you from?

A public, unauthenticated URL that costs money. The Inbound Webhook bills each execution and checks nothing. A bot that finds it runs up the bill and fills the sub-account with junk contacts. Spam-filter the form before anything is sent, and rotate the URL if it ever appears client-side.

Tags you did not mean to delete. The upsert replaces the tag list. Nothing warns you; the sales team simply finds tags missing.

A matching rule you did not choose. Whether two submissions are one contact depends on a sub-account setting, not your code. Agencies copy snapshots between sub-accounts, and the setting travels with whoever configured it.

A token with no expiry. The token keeps working until someone rotates it. Scope it to the two permissions above, store it outside the theme and the repository, and put the 90-day rotation on a calendar. When it rotates, you have seven days.

The same lead usually belongs in more than one place. If the site also feeds a CRM such as HubSpot or Pipedrive, those articles cover the same pattern with their APIs, and why webhooks fail silently covers what to log so that a broken integration is noticed in a day, not a quarter.

/Footnotes
¹ LeadConnector on WordPress.org, read 2026-10-07: version 4.0.6, 20,000+ active installations. Feature list and the statement about visitor input from the plugin readme.
² Endpoint fields, required properties and response codes from HighLevel's published OpenAPI specs at github.com/GoHighLevel/highlevel-api-docs, read 2026-10-07. Execution pricing from the premium features guide, read the same day.
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

Does the GoHighLevel WordPress plugin send form submissions to GoHighLevel? +
No. The official LeadConnector plugin embeds HighLevel's own forms, surveys, calendars, chat widget and funnel pages in WordPress. Its readme says it does not pass visitor input through PHP, so entries from Contact Form 7, Gravity Forms or WooCommerce orders need a webhook or an API call.
Is the GoHighLevel Inbound Webhook trigger free? +
No. It is a premium workflow trigger. Each sub-account gets 100 free executions in total, then each execution costs $0.01 unless the agency has a Workflow Pro plan, which starts at $10 a month for 10,000 executions. It also has no authentication, so anyone with the URL can trigger it.
How do I authenticate to the GoHighLevel API v2? +
Create a Private Integration in the sub-account settings, choose scopes such as contacts.write and opportunities.write, and send the token as Authorization: Bearer with a Version header, for example 2021-07-28, to services.leadconnectorhq.com. The token is static; HighLevel recommends rotating it every 90 days.
Why did a GoHighLevel contact lose its tags after an API update? +
Because tags sent to POST /contacts/upsert replace all of the contact's existing tags. HighLevel documents this and recommends adding tags with POST /contacts/{contactId}/tags instead, which adds to the list. Leave tags out of the upsert body.
How do I create a GoHighLevel opportunity from WordPress? +
Upsert the contact first, read contact.id from the response, then call POST /opportunities/ with locationId, pipelineId, name, status open and that contactId, plus pipelineStageId. The endpoint has no idempotency key, so a retried request can create a duplicate deal; check the upsert's new flag first.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate