Hi everyone,
I am building a synchronization workflow in n8n (Cloud version 2.22.6) to update sales data from our ERP into GoHighLevel (GHL) Business profiles (Companies).
My Goal:
We process a CSV file containing invoice totals divided by brand. We need to look up the Business in GHL using an immutable external ERP ID (codice_cliente) stored inside a GHL Company Custom Field (business.id_gestionale), and then:
-
If the Business exists: Update its financial Custom Fields via PUT.
-
If the Business is new: Create a new Business profile via POST.
The Current Workflow Setup:
-
Trigger / Read File: Imports the CSV with columns like codice_cliente, ragione_sociale, totale_ecopots, totale_lechuza, etc.
-
HTTP Request (GET): Calls GHL API V2 using Header Authentication (Authorization: Bearer pit-... and Version: 2021-04-15).
-
If Node: Checks if the business was found.
-
HTTP Request (POST / PUT): Standard HTTP nodes to route the data.
The Roadblocks we are facing:
Issue 1: The GET Lookup Failure (API V2 Limitation)
We cannot filter the GET request directly by our Custom Field.
-
If we use https://services.leadconnectorhq.com/businesses/search?locationId=XXXX&query={{ $json.codice_cliente }} it returns an empty array [] because GHL global query does not index numerical custom fields.
-
If we try appending the custom field filter in the query string (&customFields=[...]), GHL returns a 422 Error: "property customFields should not exist".
-
If we switch to a global fetch (/businesses/?limit=100), n8n pulls a massive 1.2MB payload containing all companies, which overwrites the binary/JSON context of the original incoming CSV rows, making the subsequent mapping extremely complex.
Issue 2: The POST Authorization Loop (401 Error)
When a record goes down the POST path to create a new company (https://services.leadconnectorhq.com/businesses/), GHL returns:
401 - {"message":"LocationId is not specified","error":"Unauthorized","statusCode":401}
Even though locationId is explicitly passed inside the JSON body, and the exact same Bearer pit-... token works perfectly for GET requests and PUT requests on existing IDs. It feels like GHL location-level API keys are completely restricted from using POST on the /businesses/ endpoint unless using a full OAuth Marketplace App.
Proposed Alternative (Pivot to Contacts + Company Name):
Since writing to the /businesses/ endpoint seems highly restricted with standard API keys, we are evaluating a pivot to the Contacts endpoint.
We are thinking about creating/updating Contacts instead, passing the ERP ID and invoice fields inside the Contact’s Custom Fields, while mapping the companyName field inside the contact object to link it to the business.
My Questions to the Community:
-
What is the best practice in n8n to query a GHL Business using a Custom Field value without fetching the entire 1.2MB database or running into 422 errors?
-
How can we bypass the data-overwrite behavior after an HTTP Request node so that the final PUT/POST nodes still have access to the original CSV row data (invoice totals)? Should we use the new Compare Datasets / Merge (Enrich Existing Data) node right after the file ingestion?
-
Has anyone successfully executed a POST to /businesses/ using a standard Location API Key, or is a full OAuth integration mandatory for creating companies?
-
Regarding our alternative: Do you think switching to the Contacts endpoint (while populating the companyName field inside the contact) is a solid, reliable workaround to bypass the Business endpoint API restrictions, or are there better architectural paths you would suggest?
Any advice, workaround, or workflow JSON snippet would be highly appreciated!
Dedi, this looks like two separate blockers, not one GHL bug: finding the right company by codice_cliente, then creating/updating it without the POST body being rejected. If that ERP ID only lives in a company custom field that GHL search can’t filter on, GHL may not be a reliable lookup source; keep a tiny codice_cliente -> businessId map in your own data and update by businessId.
Before rebuilding around Contacts, run one safe check: send a minimal create-company request with the same token and locationId, but without the invoice/custom-field payload. Does it still return LocationId is not specified, or only when the full CSV-mapped body is sent?
This is a really well-documented problem and you’ve already done most of the diagnostic work yourself. Let me go through each issue.
On the GET lookup limitation the cleanest workaround is to fetch all businesses once at the start of the workflow using pagination, then use a Code node to filter in-memory by your custom field value. Yes it’s a large payload, but you only call it once per workflow run, not once per CSV row. Something like:
javascript
const allBusinesses = $input.all();
const target = allBusinesses.find(b =>
b.json.customFields?.find(f => f.key id_gestionale' && f.value === $('CSV Node').item.json.codice_cliente)
return target ?
On preserving CSV data after the HTTP Request node** — use a Merge node set to "Combine" mode after your GET call. Feed both the original CSV item and the HTTP response into it. That way your PUT/POST nodes downstream still have access to all the original invoice fields alongside the GHL response data. This is the standard pattern for this exact situation.
On the POST 401 error: you're right and this is a confirmed GHL limitation. Standard Location API keys (`pit-...`) do not have permission to create new Business records via POST. That endpoint requires either a full OAuth App token or a Private Integration token with elevated scope. If full OAuth isn't an option right now, your pivot to Contacts is actually the most practical path forward.
On the Contacts pivot: it's a solid workaround and widely used. Populate `companyName` on the contact and GHL will associate it to the matching Business automatically. Store you codice_cliente` and invoice totals in Contact custom fields and you bypass the Business endpoint restrictions entirely. The main tradeoff is that your financial data lives on the Contact level rather than the Business level, which may affect reporting depending on how your GHL account is set up.
If keeping data at the Business level is important long term, the proper fix is setting up a GHL Marketplace App with OAuth but that's a bigger investment and the Contacts approach will get you unblocked today.
You have two separate errors here and it helps to fix them one at a time, because a 401 and a 422 mean very different things.
The 401 is auth or scope. GHL API V2 tokens are scoped per resource, and writing Company/Business custom fields needs the businesses write scope specifically, which is easy to miss if your token was set up for contacts. Confirm the token (or the OAuth app behind it) actually has the businesses (companies) write scope, and that you are on the V2 base URL with the correct Version header GHL requires, a missing or wrong API version header alone can throw a 401 on V2.
The 422 is payload. GHL custom fields have to be referenced by their field id, not the field name, and the body shape for custom fields is specific (an array of objects with the field id and value, not a flat key). If you are sending codice_cliente by name or as a top-level property, that is your 422. Pull the company custom-field definitions first to get the exact field id, then write using that id. Also confirm the value type matches the field’s type, sending a string into a number field 422s too.
So: fix the scope and version header for the 401, then match the exact custom-field id and body shape for the 422. Which one fires first when you run it, the 401? That has to clear before the 422 is even reachable.
[SOLVED] Incremental Contact Sales Sync from CSV to GoHighLevel Across Multiple/Duplicate Contacts with n8n
Hi everyone! I wanted to share a solution to a problem we ran into regarding incrementally updating yearly brand-specific sales data from a CSV file into GoHighLevel (GHL). This is especially useful when your CRM contains multiple or duplicate contacts (e.g., different employees or branch managers) belonging to the same company account.
After several tests, we successfully built a linear and resilient workflow that aggregates data cleanly upfront and unpacks it downstream to update every single matching duplicate contact in GoHighLevel simultaneously.
What the Workflow Does & How It Works
The workflow is engineered to automate data imports from accounting or document management systems. It calculates progressive year-over-year sales metrics broken down by individual product brands and dynamically updates the “Last Purchase Date.”
Here is the exact step-by-step logical architecture:
1. Data Extraction & Upstream Aggregation (CSV)
-
Resilient Parsing: The workflow ingests a CSV file (via Google Drive or a Manual Trigger). Accounting exports often wrap data fields inside rigid double quotes that break native CSV parsers. The first JavaScript node cleans decimal formatting (handling commas) and uses a custom row-parsing script to reconstruct a clean 8-column data object.
-
Upstream Consolidation: If the CSV contains multiple invoices or delivery notes for the same client in the same file, the code programmatically sums the totals up, generating a single cumulative record per unique store ID. This prevents n8n from shooting asynchronous parallel requests for the same client to GHL, avoiding API rate locks or race conditions (where operations overwrite each other).
2. Contact Lookup on CRM (GET)
-
The workflow sends a GET request to GoHighLevel’s /contacts/ endpoint. To bypass variations or discrepancies in corporate email addresses across duplicate entries, the query searches using the Company Name (or a unique External Store ID).
-
GoHighLevel responds with a JSON payload containing a contacts array listing every single contact that matches that specific company query.
3. The Logical Fork (Merge & IF)
-
A Merge node pairs the fresh aggregated CSV data with the search output from GHL.
-
An IF node evaluates whether the company already exists in the CRM (meta.total > 0). If the client is new, the workflow branches to the FALSE path to create a new record (POST). If it exists, it moves along the TRUE path for updates.
4. Duplicate Unpacking & Linearization (Scompatta Duplicati)
-
This is the secret sauce for handling duplicate records. Positioned immediately after the TRUE output of the IF node, a dedicated JavaScript node isolates GHL’s returned contact array and “unrolls” it into independent execution lines for n8n.
-
For example, if 3 duplicate contact profiles exist under the same company name in GoHighLevel, this node instantly converts that single execution thread into 3 separate items flowing downstream in parallel.
5. Incremental Calculation (Brand Mathematics)
- The subsequent JavaScript node processes these unpacked contacts one by one. It looks inside the custom fields of that specific Contact ID (e.g.,
totale_ecopots_2026, totale_lechuza_2026), extracts the current historical numerical value, and mathematically adds the new sales volume calculated from the current CSV file. It outputs a clean, ready-to-save payload.
6. Mass Custom Field Updates (PUT)
-
The final HTTP Request node executes a PUT command pointed at a dynamic URL string: https://services.leadconnectorhq.com/contacts/``{{ $json.id_contatto }}.
-
By turning off the Execute Once toggle inside the node’s advanced settings, n8n executes a perfect sequential loop. It fires off as many individual PUT requests as there are duplicate profiles generated by the unpacking code, seamlessly updating historical metrics across every single representative profile tied to that account in the CRM.
Thanks to everyone for the previous pointers. I hope this structural layout helps anyone dealing with complex incremental calculations over duplicate database profiles!