Create and Update Contacts Using GoHighLevel API v2

by Fahim

When you sync customer data from a custom checkout, mobile app, or external database into HighLevel, relying on Zapier or basic webhooks gets messy fast. You need direct API calls that search, create, and update records without throwing duplicate errors or wiping out existing tags.

We’ll walk through the exact Node.js code needed to search for contacts, create new ones, update existing records, and handle custom fields properly using HighLevel API v2.

Code editor showing GoHighLevel API v2 contact creation and update script
Code editor showing GoHighLevel API v2 contact creation and update script

Prerequisites and Authentication Setup

HighLevel API v2 uses OAuth 2.0 access tokens or Private Integration tokens instead of legacy v1 API keys. If you haven’t generated one yet, check out our guide on how to create a GoHighLevel private integration to grab a bearer token with the right permissions.

Every request sent to API v2 needs three core headers:

  • Authorization: Your bearer token (e.g., Bearer pit-12345...).
  • Version: The API version date. HighLevel expects 2021-07-28.
  • Content-Type: Set to application/json for POST and PUT requests.

Forget the Version header and your calls will fail with bizarre 400 or 404 errors. You’ll also need your sub-account Location ID. If you’re not sure where to grab that, see our guide on how to find and use your GoHighLevel Location ID.

Here’s how to structure these credentials in your project config:

GHL_ACCESS_TOKEN=pit-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
GHL_LOCATION_ID=loc_xxxxxxxxxxxxxxxxxxxx
GHL_API_BASE=https://services.leadconnectorhq.com

Make sure your token has at least the contacts.write and contacts.readonly scopes enabled in your integration settings.

Creating a Contact with POST /contacts/

To create a contact, send a POST request to https://services.leadconnectorhq.com/contacts/. The request body must include your locationId alongside standard fields like email, phone, and name.

Here’s a straightforward Node.js function using native fetch to create a contact:

async function createContact(contactData) { const response = await fetch('https://services.leadconnectorhq.com/contacts/', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.GHL_ACCESS_TOKEN}`, 'Version': '2021-07-28', 'Content-Type': 'application/json' }, body: JSON.stringify({ locationId: process.env.GHL_LOCATION_ID, firstName: contactData.firstName, lastName: contactData.lastName, name: `${contactData.firstName} ${contactData.lastName}`, email: contactData.email, phone: contactData.phone, address1: contactData.address1 || '', city: contactData.city || '', state: contactData.state || '', postalCode: contactData.postalCode || '', tags: contactData.tags || ['api-lead'], source: 'External App Sync' }) }); if (!response.ok) { const errorBody = await response.text(); throw new Error(`Create contact failed (${response.status}): ${errorBody}`); } const data = await response.json(); return data.contact;
}

When this succeeds (usually 350ms to 500ms response time), the payload returns a contact object containing the new contact’s id.

Handling the Duplicate Prevention Problem

If you hit POST /contacts/ with an email or phone number that already exists in that sub-account, HighLevel won’t merge it automatically. It throws a 400 Bad Request or 422 Unprocessable Entity stating a duplicate record exists.

In production, you shouldn’t blindly POST. Search first or catch duplicate errors gracefully. You can query contacts using the duplicate search endpoint or standard search parameters detailed in the official HighLevel API documentation.

Here’s how to look up an existing contact by email or phone:

async function findContact(query) { const url = new URL('https://services.leadconnectorhq.com/contacts/'); url.searchParams.append('locationId', process.env.GHL_LOCATION_ID); url.searchParams.append('query', query); const response = await fetch(url.toString(), { method: 'GET', headers: { 'Authorization': `Bearer ${process.env.GHL_ACCESS_TOKEN}`, 'Version': '2021-07-28' } }); if (!response.ok) { const errorBody = await response.text(); throw new Error(`Search failed (${response.status}): ${errorBody}`); } const data = await response.json(); if (data.contacts && data.contacts.length > 0) { return data.contacts[0]; } return null;
}

The query param performs a fuzzy search across email, phone, and name. If you need an exact match, double-check the returned contact’s email or phone property in your code before firing an update.

Updating a Contact with PUT /contacts/{contactId}

Once you have the contact ID, send a PUT /contacts/{contactId} request. In API v2, the contact ID lives in the URL path, while your updated properties go in the JSON body.

You don’t need to pass locationId in the body of a PUT request, though it won’t hurt if you leave it in.

async function updateContact(contactId, updateData) { const response = await fetch(`https://services.leadconnectorhq.com/contacts/${contactId}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${process.env.GHL_ACCESS_TOKEN}`, 'Version': '2021-07-28', 'Content-Type': 'application/json' }, body: JSON.stringify({ firstName: updateData.firstName, lastName: updateData.lastName, phone: updateData.phone, tags: updateData.tags }) }); if (!response.ok) { const errorBody = await response.text(); throw new Error(`Update failed (${response.status}): ${errorBody}`); } const data = await response.json(); return data.contact;
}

Watch out: passing a tags array in a PUT request completely overwrites the contact’s existing tags. If you want to append tags without wiping old ones, fetch the current tags first, merge them with a Set, and pass the combined array back.

Formatting Custom Fields in API v2

Custom fields trip up almost everyone moving from API v1. In v1, you could send custom fields as root-level keys. In API v2, you have to submit them as an array of objects inside the customFields key.

Each item in the array needs the custom field’s unique ID and its value:

{ "customFields": [ { "id": "q3B7Z8kR0y9wXv1pLm5N", "value": "Tier 2 Support" }, { "id": "mK9pL2vR8wXy1zAb3CdE", "value": 149.990000000000009094947017729282379150390625 }, { "id": "tX7vB9kL1mN3pQ5rS2wZ", "value": [ "Option A", "Option C" ] } ]
}

For multi-select or checkbox fields, pass an array of strings that match the option labels in HighLevel exactly. For dates, use standard ISO 8601 strings (like 2026-03-31).

If you’re also reading custom field data out of HighLevel automations, check our guide on how to send custom values and contact fields in GoHighLevel webhooks.

Putting It Together: The Complete Upsert Function

Here is a clean, production-ready module combining search, create, and update into a reusable upsertContact helper. It searches by email first: if the contact exists, it updates their data and merges tags; if not, it creates a fresh record.

import 'dotenv/config'; const API_BASE = 'https://services.leadconnectorhq.com';
const HEADERS = { 'Authorization': `Bearer ${process.env.GHL_ACCESS_TOKEN}`, 'Version': '2021-07-28', 'Content-Type': 'application/json'
}; export async function upsertContact(payload) { if (!payload.email) { throw new Error('Email is required for upsert.'); } // 1. Search for existing contact const searchUrl = new URL(`${API_BASE}/contacts/`); searchUrl.searchParams.append('locationId', process.env.GHL_LOCATION_ID); searchUrl.searchParams.append('query', payload.email); const searchRes = await fetch(searchUrl.toString(), { method: 'GET', headers: { 'Authorization': HEADERS['Authorization'], 'Version': HEADERS['Version'] } }); if (!searchRes.ok) { throw new Error(`Search request failed with status ${searchRes.status}`); } const searchData = await searchRes.json(); const existing = searchData.contacts?.find( c => c.email?.toLowerCase() === payload.email.toLowerCase() ); // 2. If contact exists, update if (existing) { const mergedTags = Array.from( new Set([...(existing.tags || []), ...(payload.tags || [])]) ); const updateBody = { firstName: payload.firstName || existing.firstName, lastName: payload.lastName || existing.lastName, phone: payload.phone || existing.phone, tags: mergedTags }; if (payload.customFields) { updateBody.customFields = payload.customFields; } const updateRes = await fetch(`${API_BASE}/contacts/${existing.id}`, { method: 'PUT', headers: HEADERS, body: JSON.stringify(updateBody) }); if (!updateRes.ok) { const err = await updateRes.text(); throw new Error(`Update failed: ${err}`); } const updatedData = await updateRes.json(); return { action: 'updated', contact: updatedData.contact }; } // 3. Otherwise, create new contact const createBody = { locationId: process.env.GHL_LOCATION_ID, firstName: payload.firstName, lastName: payload.lastName, name: `${payload.firstName} ${payload.lastName}`.trim(), email: payload.email, phone: payload.phone, tags: payload.tags || [], customFields: payload.customFields || [] }; const createRes = await fetch(`${API_BASE}/contacts/`, { method: 'POST', headers: HEADERS, body: JSON.stringify(createBody) }); if (!createRes.ok) { const err = await createRes.text(); throw new Error(`Create failed: ${err}`); } const createdData = await createRes.json(); return { action: 'created', contact: createdData.contact };
}

You can run this function directly inside an Express route handler, an AWS Lambda function, or a background worker:

async function run() { try { const result = await upsertContact({ firstName: 'Sarah', lastName: 'Connor', email: 'sconnor@example.com', phone: '+15550192834', tags: ['customer', 'plan-pro'], customFields: [ { id: 'q3B7Z8kR0y9wXv1pLm5N', value: 'Active' } ] }); console.log(`Contact ${result.action} with ID: ${result.contact.id}`); } catch (err) { console.error('Error running upsert:', err.message); }
} run();

Rate Limits and Error Handling

HighLevel throttles API v2 requests across sub-accounts. If you run batch syncs without rate-limiting, you will hit HTTP 429 errors quickly.

  • Burst limit: ~10 requests per second per IP/token combination.
  • Daily limit: Up to 200,000 requests per day depending on account plan and app integration tier.
  • Duplicate errors: Inspect the response body for duplicatedContact or error messages mentioning existing records when POST requests fail.

For high-throughput background syncs, wrap your fetch calls in an exponential backoff helper or push jobs through a queue like BullMQ so 429s pause execution for 500ms to 2000ms before retrying.

For more details on managing token lifecycles and scopes, check out our walkthrough on how to authenticate and pass Location ID in GoHighLevel API v2, and refer to the GoHighLevel Help Center for recent permission changes.

Frequently Asked Questions

Why am I getting a 401 Unauthorized error with a valid token?

Make sure your header uses exact casing: Authorization: Bearer . Also verify the token hasn’t expired and that your Private Integration has both contacts.write and contacts.readonly scopes enabled.

How do I find custom field IDs for the payload?

Make a GET request to https://services.leadconnectorhq.com/locations/{locationId}/customFields using your bearer token and the locations/customFields.readonly scope. The response returns an array of all custom fields with their string IDs.

Can I delete tags from a contact using the API?

Yes. Send a PUT /contacts/{contactId} request with the tags array containing only the tags you want to keep. Any existing tags omitted from the array will be removed from the contact record.

What is the phone number format required by HighLevel?

Use international E.164 format (such as +15551234567). Passing a 10-digit local number without a country code causes HighLevel to guess the country based on Sub-Account settings, which frequently triggers validation failures.

Next Steps

Now that your contact sync is running cleanly, you can hook your API events into internal HighLevel automations. Read our tutorial on how to trigger a GoHighLevel workflow with inbound webhooks to fire campaigns the second a contact gets updated.

all_in_one_marketing_tool