---
title: "WordPress GoHighLevel Integration: Webhook vs API v2"
description: "WordPress to GoHighLevel: the Inbound Webhook trigger and what it costs, Private Integration tokens, contact upsert that wipes tags, and opportunities."
url: "https://wpwebhooks.org/blog/wordpress-gohighlevel-integration/"
date: "2026-10-07"
---

# WordPress GoHighLevel Integration: Webhook vs API v2

**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](https://wordpress.org/plugins/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 trigger | API v2 + Private Integration token |
| --- | --- | --- |
| Authentication | None. The URL is the secret | Bearer token with chosen scopes |
| Cost | 100 free executions per sub-account, then $0.01 each | No per-call fee |
| What it creates | Whatever the workflow does with the data | Exactly the record you send |
| Body format | JSON only, one-word keys | JSON, defined per endpoint |
| Changing the payload | Re-select the mapping reference | Change the request |

/ Inbound Webhook

## How does the GoHighLevel Inbound Webhook trigger work?

You add the [Inbound Webhook trigger](https://help.gohighlevel.com/support/solutions/articles/155000003147-workflow-trigger-inbound-webhook) 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](https://help.gohighlevel.com/support/solutions/articles/155000005678-how-to-enable-and-rebill-workflow-premium-features), 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](https://marketplace.gohighlevel.com/docs/Authorization/PrivateIntegrationsToken/). 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](https://marketplace.gohighlevel.com/docs/Versioning/) 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](https://marketplace.gohighlevel.com/docs/2021-07-28/ghl/contacts/upsert-contact/). 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": "anna@example.com",
  "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](https://marketplace.gohighlevel.com/docs/2021-07-28/ghl/contacts/add-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.](https://wpwebhooks.org/blog/wordpress-gohighlevel-integration/og_image.jpg)

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/](https://marketplace.gohighlevel.com/docs/2021-07-28/ghl/opportunities/create-opportunity/) 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](https://github.com/GoHighLevel/highlevel-api-docs/blob/main/common/common-schemas.json).² 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](https://marketplace.gohighlevel.com/docs/other/rate-limits/): 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.

| Concern | Hand-rolled wp\_remote\_post | Webhook Actions |
| --- | --- | --- |
| Running HighLevel workflows, calendars, chat | Not this. That is HighLevel's job | Not this either. It sends WordPress events out; there is no HighLevel connector, you configure the request yourself |
| Which events | One add\_action per form plugin, written by you | Any WordPress action as a trigger: Contact Form 7, Gravity Forms, WooCommerce orders, user registration |
| The Private Integration token | A constant in wp-config.php | A Bearer credential in the Credentials Vault, redacted in every log |
| The Version header | Written in your array | A static custom request header |
| locationId and other constants | Written in your array | A short pre-dispatch Code Glue snippet; field mapping moves values but cannot invent a constant |
| Opportunity after the upsert | A second request in the same function | A chained webhook that fires on the upsert's 2xx and maps args.0.response.body.contact.id to contactId |
| A 429 or 5xx | Lost unless you wrote retry logic | Retried with exponential backoff, five attempts by default. The rate-limit headers are not read |
| A 422 from a bad field | Invisible | Marked 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.

[Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip)

/ 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](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/) 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](https://wpwebhooks.org/blog/wordpress-hubspot-integration-methods/) or [Pipedrive](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/), those articles cover the same pattern with their APIs, and [why webhooks fail silently](https://wpwebhooks.org/blog/why-wordpress-webhooks-silently-fail-in-production/) covers what to log so that a broken integration is noticed in a day, not a quarter.

/Footnotes

¹ LeadConnector on [WordPress.org](https://wordpress.org/plugins/leadconnector/), 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](https://github.com/GoHighLevel/highlevel-api-docs), read 2026-10-07. Execution pricing from the premium features guide, read the same day.

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"WordPress GoHighLevel Integration: Webhook vs API v2","description":"WordPress to GoHighLevel: the Inbound Webhook trigger and what it costs, Private Integration tokens, contact upsert that wipes tags, and opportunities.","datePublished":"2026-10-07","dateModified":"2026-10-07","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-gohighlevel-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-gohighlevel-integration/og_image.jpg","width":1200,"height":630,"caption":"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."},"keywords":["wordpress gohighlevel integration","gohighlevel wordpress","gohighlevel wordpress plugin","gohighlevel webhook","gohighlevel inbound webhook","gohighlevel api v2","leadconnector wordpress"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress GoHighLevel Integration: Webhook vs API v2","item":"https://wpwebhooks.org/blog/wordpress-gohighlevel-integration/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Does the GoHighLevel WordPress plugin send form submissions to GoHighLevel?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Is the GoHighLevel Inbound Webhook trigger free?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"How do I authenticate to the GoHighLevel API v2?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Why did a GoHighLevel contact lose its tags after an API update?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"How do I create a GoHighLevel opportunity from WordPress?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}

{"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-gohighlevel-integration.png","caption":"FIG 01 — Upsert first, then the opportunity with the contactId from the response; tags go through their own endpoint","description":"A form submission is queued. The worker sends POST /contacts/upsert to services.leadconnectorhq.com with a Bearer Private Integration token and a Version header. HighLevel matches the contact using the sub-account Allow Duplicate Contact setting and answers 200 with the contact id and a new flag. Tags are added with POST /contacts/{id}/tags, because tags sent on the upsert replace existing ones. If the contact is new, POST /opportunities/ creates a deal with pipelineId, status open and the contactId. A 429 or 5xx is retried; 401 and 422 are not.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"}
```
