In short
- 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
| Method | Path | Scopes | Rate limit |
|---|---|---|---|
| GET | /contacts/{contactId} | contacts.readonly | See GHL API docs for current limits |
| POST | /contacts | contacts.write | See GHL API docs for current limits |
| PUT | /contacts/{contactId} | contacts.write | See GHL API docs for current limits |
| DELETE | /contacts/{contactId} | contacts.write | See GHL API docs for current limits |
| GET | /contacts/search | contacts.readonly | See 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.
Recipes
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": "EMAIL_ADDRESS",
"phone": "PHONE_NUMBER",
"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: 'EMAIL_ADDRESS',
phone: 'PHONE_NUMBER',
tags: ['lead', 'webinar-signup'],
}),
}
);
const data = await response.json();
console.log(data.contact.id); // The new contact's IDPython (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": "EMAIL_ADDRESS",
"phone": "PHONE_NUMBER",
"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": "EMAIL_ADDRESS",
"phone": "PHONE_NUMBER",
"tags": ["lead", "webinar-signup"],
"dateAdded": "2026-05-28T10:00:00.000Z",
"dateUpdated": "2026-05-28T10:00:00.000Z"
}
}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"])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
- 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.
MCP tool definition
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. PHONE_NUMBER" },
"tags": { "type": "array", "items": { "type": "string" } }
},
"required": ["email"]
}
}