RocketLauncher AI

API and Recipes

GoHighLevel Contacts API: Create and Update Recipes

By Marnix Geerkens. Published 2026-05-28. Updated 2026-05-28.

In short

The GoHighLevel Contacts API lets you create, read, update, and upsert contact records from any external system. Use the POST /contacts endpoint to create, PUT /contacts/{id} to update, or a lookup-then-upsert pattern to avoid duplicates. All requests require a Bearer token from your private integration or an OAuth app.

  • Create a contact: POST /contacts with at minimum an email or phone number.
  • Update a contact: PUT /contacts/{contactId} with only the fields you want to change.
  • Avoid duplicates: look up by email first, then create or update based on the result.
  • Tag and custom-field values are set in the same payload as core contact fields.

Endpoints

MethodPathScopesRate limit
GET/contacts/{contactId}contacts.readonlySee GHL API docs for current limits
POST/contactscontacts.writeSee GHL API docs for current limits
PUT/contacts/{contactId}contacts.writeSee GHL API docs for current limits
DELETE/contacts/{contactId}contacts.writeSee GHL API docs for current limits
GET/contacts/searchcontacts.readonlySee GHL API docs for current limits

Authentication

Every request needs an Authorization header with a Bearer token. You get this token either from a Private Integration Token (found under Settings > Integrations in your sub-account) or from the OAuth 2.0 flow if you are building a marketplace app.

The Location-Id header is required for all contact endpoints. This is the sub-account ID, sometimes called the location ID. You can find it in Settings > Business Profile or extract it from the GHL dashboard URL.

Tokens expire. Private integration tokens are long-lived but can be revoked. OAuth access tokens expire after a set period; use the refresh token to get a new one without asking the user to re-authorize.

Always confirm the current token scopes and rate limits in the official GoHighLevel API documentation at highlevel.stoplight.io, as these details change between API versions.

Recipe 1. Create a new contact

Send a POST request to /contacts with the contact fields you want to set. At minimum, include either an email or a phone number. GoHighLevel will reject the request if neither is present.

Tags are passed as an array of strings. Custom field values go inside the customFields array, each item having an id (the custom field ID from Settings > Custom Fields) and a value.

cURL
curl -X POST https://services.leadconnectorhq.com/contacts \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Version: 2021-07-28" \
  -d '{
    "locationId": "YOUR_LOCATION_ID",
    "firstName": "Jane",
    "lastName": "Smith",
    "email": "jane@example.com",
    "phone": "+14155550101",
    "tags": ["lead", "webinar-signup"],
    "customFields": [
      { "id": "CUSTOM_FIELD_ID", "value": "Acme Corp" }
    ]
  }'
Node.js (fetch)
const response = await fetch(
  'https://services.leadconnectorhq.com/contacts',
  {
    method: 'POST',
    headers: {
      Authorization: 'Bearer YOUR_TOKEN',
      'Content-Type': 'application/json',
      Version: '2021-07-28',
    },
    body: JSON.stringify({
      locationId: 'YOUR_LOCATION_ID',
      firstName: 'Jane',
      lastName: 'Smith',
      email: 'jane@example.com',
      phone: '+14155550101',
      tags: ['lead', 'webinar-signup'],
    }),
  }
);
const data = await response.json();
console.log(data.contact.id); // The new contact's ID
Python (requests)
import requests

url = "https://services.leadconnectorhq.com/contacts"
headers = {
    "Authorization": "Bearer YOUR_TOKEN",
    "Content-Type": "application/json",
    "Version": "2021-07-28",
}
payload = {
    "locationId": "YOUR_LOCATION_ID",
    "firstName": "Jane",
    "lastName": "Smith",
    "email": "jane@example.com",
    "phone": "+14155550101",
    "tags": ["lead", "webinar-signup"],
}
res = requests.post(url, json=payload, headers=headers)
res.raise_for_status()
print(res.json()["contact"]["id"])
Response (201 Created)
{
  "contact": {
    "id": "abc123xyz",
    "locationId": "YOUR_LOCATION_ID",
    "firstName": "Jane",
    "lastName": "Smith",
    "email": "jane@example.com",
    "phone": "+14155550101",
    "tags": ["lead", "webinar-signup"],
    "dateAdded": "2026-05-28T10:00:00.000Z",
    "dateUpdated": "2026-05-28T10:00:00.000Z"
  }
}

Recipe 2. Update an existing contact

Use PUT /contacts/{contactId} to update one or more fields. You only need to send the fields you want to change; unset fields keep their current values.

To add a tag without removing existing ones, read the contact first, merge the tag arrays, then PUT the merged array. The API replaces the whole tags array on update.

cURL
curl -X PUT https://services.leadconnectorhq.com/contacts/CONTACT_ID \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Version: 2021-07-28" \
  -d '{
    "firstName": "Jane",
    "lastName": "Doe",
    "tags": ["lead", "webinar-signup", "qualified"]
  }'
Node.js (fetch)
const contactId = 'CONTACT_ID';
const response = await fetch(
  `https://services.leadconnectorhq.com/contacts/${contactId}`,
  {
    method: 'PUT',
    headers: {
      Authorization: 'Bearer YOUR_TOKEN',
      'Content-Type': 'application/json',
      Version: '2021-07-28',
    },
    body: JSON.stringify({
      firstName: 'Jane',
      lastName: 'Doe',
      tags: ['lead', 'webinar-signup', 'qualified'],
    }),
  }
);
const data = await response.json();
console.log(data.contact.dateUpdated);
Python (requests)
import requests

contact_id = "CONTACT_ID"
url = f"https://services.leadconnectorhq.com/contacts/{contact_id}"
headers = {
    "Authorization": "Bearer YOUR_TOKEN",
    "Content-Type": "application/json",
    "Version": "2021-07-28",
}
payload = {
    "firstName": "Jane",
    "lastName": "Doe",
    "tags": ["lead", "webinar-signup", "qualified"],
}
res = requests.put(url, json=payload, headers=headers)
res.raise_for_status()
print(res.json()["contact"]["dateUpdated"])

Recipe 3. Upsert a contact by email (lookup then create or update)

GoHighLevel does not have a native upsert endpoint. The standard pattern is: search for the contact by email, then create it if missing or update it if found. This is safe to run from a webhook handler or a scheduled sync.

If your sync is high-volume, add a short delay between batches to stay within rate limits. Check the official API docs for the current per-minute limits.

Node.js (fetch)
async function upsertContact(email, fields, token, locationId) {
  const base = 'https://services.leadconnectorhq.com';
  const headers = {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
    Version: '2021-07-28',
  };

  // 1. Search for existing contact
  const search = await fetch(
    `${base}/contacts/search?locationId=${locationId}&email=${encodeURIComponent(email)}`,
    { headers }
  );
  const { contacts } = await search.json();

  if (contacts && contacts.length > 0) {
    // 2a. Update existing
    const id = contacts[0].id;
    const update = await fetch(`${base}/contacts/${id}`, {
      method: 'PUT',
      headers,
      body: JSON.stringify(fields),
    });
    return (await update.json()).contact;
  } else {
    // 2b. Create new
    const create = await fetch(`${base}/contacts`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ locationId, email, ...fields }),
    });
    return (await create.json()).contact;
  }
}
Python (requests)
import requests

def upsert_contact(email, fields, token, location_id):
    base = "https://services.leadconnectorhq.com"
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "Version": "2021-07-28",
    }

    # 1. Search
    search = requests.get(
        f"{base}/contacts/search",
        params={"locationId": location_id, "email": email},
        headers=headers,
    )
    search.raise_for_status()
    contacts = search.json().get("contacts", [])

    if contacts:
        # 2a. Update
        contact_id = contacts[0]["id"]
        res = requests.put(
            f"{base}/contacts/{contact_id}",
            json=fields,
            headers=headers,
        )
    else:
        # 2b. Create
        res = requests.post(
            f"{base}/contacts",
            json={"locationId": location_id, "email": email, **fields},
            headers=headers,
        )
    res.raise_for_status()
    return res.json()["contact"]

Common errors and fixes

401 Unauthorized: your token is missing, expired, or does not have the contacts.write scope. Regenerate the token or check the scope list in your integration settings.

422 Unprocessable Entity: the payload is missing a required field (usually both email and phone are absent) or a custom field ID does not exist in this location.

429 Too Many Requests: you have hit the rate limit. Add exponential back-off with jitter to your retry logic. The Retry-After header tells you how many seconds to wait.

404 Not Found on PUT: the contactId does not belong to the location identified by your token. Double-check the locationId and contactId match the same sub-account.

Duplicate contacts: if you call POST twice with the same email, GoHighLevel may create two contacts. Always run the lookup-then-upsert pattern for external syncs. Confirm current deduplication behavior in the official GHL API docs.

Copy as an MCP tool

MCP tool definition (JSON)
{
  "name": "ghl_upsert_contact",
  "description": "Create or update a contact in GoHighLevel by email. Returns the contact object.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "email": { "type": "string", "description": "Contact email address" },
      "firstName": { "type": "string" },
      "lastName": { "type": "string" },
      "phone": { "type": "string", "description": "E.164 format, e.g. +14155550101" },
      "tags": { "type": "array", "items": { "type": "string" } }
    },
    "required": ["email"]
  }
}

You need a GoHighLevel account to use the API. Start the 30-day trial through our link.

Frequently asked questions

Does GoHighLevel have a native upsert endpoint?

No. As of 2026, the API does not have a single upsert call. The standard workaround is to search by email, then create or update based on the result. Check the official GoHighLevel API changelog for any new endpoints that may have been added.

What is the locationId and where do I find it?

The locationId is the sub-account ID. You can find it in Settings > Business Profile inside the sub-account, or extract it from the dashboard URL. Every contact endpoint requires it in the request header.

Can I set custom field values when creating a contact?

Yes. Include a customFields array in the POST or PUT body. Each item needs the custom field ID (from Settings > Custom Fields in the sub-account) and the value you want to set.

How do I add a tag without removing existing tags?

The API replaces the entire tags array on every PUT. So you need to fetch the contact first, merge the existing tags with the new ones, then PUT the merged array.

What happens if I send a phone number in the wrong format?

GoHighLevel expects phone numbers in E.164 format (e.g. +14155550101). A number without the country code may be accepted but can cause issues with SMS sending. Always format numbers before sending them to the API.

Related reading

Opportunities and pipeline stage updatesMove leads through your pipeline programmatically.Calendar appointment booking via APIBook appointments from external forms and tools.Inbound webhook: payment events from Stripe and ShopifyTrigger GHL workflows from payment processors.GoHighLevel API overviewAll API and webhook recipes in one place.