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 aVersionheader againstservices.leadconnectorhq.com.POST /contacts/upsertcreates or updates, andPOST /opportunities/needs thecontactIdthe upsert returned. - An upsert that sends
tagsreplaces 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 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 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.
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.
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.
| 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;
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.