How to Authenticate and Pass Location ID in GoHighLevel API v2

by Fahim

If you’re staring at a 401 Unauthorized or locationId is required error from GoHighLevel API v2, your request is missing one of two things: the mandatory headers or the sub-account scope. API v1 let you throw a static API key into query params and call it a day. API v2 completely separates authentication from sub-account context, requiring OAuth 2.0 / Private Integration Bearer tokens, an explicit Version header, and strict sub-account scoping.

I hit this firsthand while migrating an internal sync script from v1. My token was valid, but requests kept returning 422 Unprocessable Entity simply because I wrote location_id in snake_case instead of camelCase locationId in the JSON body. Here is how to structure your auth headers, where to pass the Location ID for each HTTP method, and tested examples in Node.js, Python, and cURL.

How to Authenticate and Pass Location ID in GoHighLevel API v2
How to Authenticate and Pass Location ID in GoHighLevel API v2

The Core Requirements for GoHighLevel API v2 Calls

Every request sent to https://services.leadconnectorhq.com/ needs these three headers, or the API gateway will drop it before it reaches an endpoint handler:

  • Authorization: Bearer YOUR_ACCESS_TOKEN
  • Version: 2021-07-28 (Mandatory for all v2 endpoints)
  • Content-Type: application/json (Required on POST, PUT, and PATCH)

Without the Version: 2021-07-28 header, HighLevel’s gateway either rejects the call or fails to parse your payload. You can check the current schema specs in the GoHighLevel Marketplace API Documentation.

Where Does the Location ID Go?

In API v2, the Location ID identifies the specific sub-account you want to read from or write to. Where you place it depends entirely on the HTTP method.

1. GET Requests: Query Parameters

When fetching collections (like contacts, calendars, or pipelines), pass locationId as a URL query parameter.

https://services.leadconnectorhq.com/contacts/?locationId=Ve94kLs02mPq18ZtYuOp

If you omit this parameter on a sub-account-scoped token, the endpoint throws a 400 Bad Request complaining that locationId was not provided.

2. POST and PUT Requests: JSON Body

When creating or updating records (such as creating a contact or updating a custom field), pass locationId at the root level of the JSON payload. Watch the casing closely:

{ "locationId": "Ve94kLs02mPq18ZtYuOp", "firstName": "Alex", "lastName": "Rivera", "email": "alex@example.com", "phone": "+15551234567"
}

If you’re triggering outbound payloads from inside HighLevel workflows rather than polling via the API, check our guide on how to send custom values and contact fields in GoHighLevel webhooks.

How to Get Your Token and Location ID

Before writing code, grab your credentials:

  • For single-client scripts or private integrations: Create a Private Integration inside the sub-account under Settings > Developers > Private Integrations. This generates a permanent access token pre-configured with your chosen scopes. Follow our step-by-step setup in creating a GoHighLevel private integration.
  • For finding the Location ID: Open the sub-account in your browser. The URL contains the ID right after /location/ (e.g., app.gohighlevel.com/v2/location/Ve94kLs02mPq18ZtYuOp/dashboard). For other lookup methods, see our guide on finding your sub-account location ID.

Complete Node.js Implementation (Fetch API)

Here is a working Node.js script using native fetch (Node 18+) that queries contacts with a query param and creates a contact with a JSON body.

import dotenv from 'dotenv';
dotenv.config(); const GHL_API_BASE = 'https://services.leadconnectorhq.com';
const ACCESS_TOKEN = process.env.GHL_ACCESS_TOKEN;
const LOCATION_ID = process.env.GHL_LOCATION_ID; const headers = { 'Authorization': `Bearer ${ACCESS_TOKEN}`, 'Version': '2021-07-28', 'Content-Type': 'application/json', 'Accept': 'application/json'
}; async function getContacts() { const url = new URL(`${GHL_API_BASE}/contacts/`); url.searchParams.append('locationId', LOCATION_ID); url.searchParams.append('limit', '10'); const response = await fetch(url.toString(), { method: 'GET', headers: headers }); if (!response.ok) { const errorText = await response.text(); throw new Error(`GET failed (${response.status}): ${errorText}`); } const data = await response.json(); console.log(`Found ${data.contacts.length} contacts.`); return data.contacts;
} async function createContact(contactData) { const response = await fetch(`${GHL_API_BASE}/contacts/`, { method: 'POST', headers: headers, body: JSON.stringify({ locationId: LOCATION_ID, ...contactData }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`POST failed (${response.status}): ${errorText}`); } const result = await response.json(); console.log(`Contact created with ID: ${result.contact.id}`); return result.contact;
} async function run() { try { await getContacts(); await createContact({ firstName: 'Dev', lastName: 'Tester', email: 'dev.tester@example.com', phone: '+15559876543' }); } catch (err) { console.error(err.message); }
} run();

Store these credentials in a local .env file so you never commit secrets to Git:

GHL_ACCESS_TOKEN=pit-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d
GHL_LOCATION_ID=Ve94kLs02mPq18ZtYuOp

Complete Python Implementation (Requests)

If you’re building backend sync workers in Python, use the requests package. Here is the equivalent implementation handling both GET queries and POST bodies:

import os
import requests BASE_URL = "https://services.leadconnectorhq.com"
ACCESS_TOKEN = os.getenv("GHL_ACCESS_TOKEN", "pit-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d")
LOCATION_ID = os.getenv("GHL_LOCATION_ID", "Ve94kLs02mPq18ZtYuOp") headers = { "Authorization": f"Bearer {ACCESS_TOKEN}", "Version": "2021-07-28", "Content-Type": "application/json", "Accept": "application/json"
} def fetch_contacts(limit=5): url = f"{BASE_URL}/contacts/" params = { "locationId": LOCATION_ID, "limit": limit } response = requests.get(url, headers=headers, params=params) response.raise_for_status() data = response.json() contacts = data.get("contacts", []) print(f"Retrieved {len(contacts)} contacts") return contacts def create_contact(first_name, last_name, email, phone): url = f"{BASE_URL}/contacts/" payload = { "locationId": LOCATION_ID, "firstName": first_name, "lastName": last_name, "email": email, "phone": phone } response = requests.post(url, headers=headers, json=payload) response.raise_for_status() contact = response.json().get("contact", {}) print(f"Created contact: {contact.get('id')}") return contact if __name__ == "__main__": fetch_contacts(limit=3) create_contact("Jane", "Doe", "jane.doe@example.com", "+15554443322")

Fast Terminal Testing with cURL

When an endpoint acts up in your application code, test it directly in your terminal with cURL to isolate whether it’s a code bug or a token/permission issue.

Here is a GET request searching contacts by email:

curl --request GET  --url 'https://services.leadconnectorhq.com/contacts/?locationId=Ve94kLs02mPq18ZtYuOp&query=jane.doe@example.com'  --header 'Authorization: Bearer pit-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d'  --header 'Version: 2021-07-28'  --header 'Accept: application/json'

And here is a POST request creating a task under a contact:

curl --request POST  --url 'https://services.leadconnectorhq.com/contacts/Ve94kLs02mPq18ZtYuOp/tasks'  --header 'Authorization: Bearer pit-1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d'  --header 'Content-Type: application/json'  --header 'Version: 2021-07-28'  --data '{ "title": "Follow up on proposal", "dueDate": "2025-04-01T15:00:00Z", "completed": false }'

Note that for sub-resource endpoints like tasks, the ID often lives in the URL path itself. Double-check whether an endpoint expects a path param or a body param in the GoHighLevel Knowledge Base.

Debugging Common API v2 Errors

These are the three most common error codes you will hit when working with v2 auth and how to solve them:

1. HTTP 401: Unauthorized (Invalid JWT or Token Expired)

This triggers if your OAuth token expired, there is trailing whitespace in your Authorization header string, or you used an old API v1 key against a services.leadconnectorhq.com route. Verify that your token starts with pit- (for Private Integrations) or is a fresh OAuth 2.0 access token.

2. HTTP 422: Unprocessable Entity

A 422 almost always means a key naming mismatch in your request body. The most common culprit is casing:

  • Wrong: "location_id": "xyz"
  • Wrong: "LocationId": "xyz"
  • Correct: "locationId": "xyz"

3. HTTP 403: Forbidden (Scope Mismatch)

If your token is valid but returns 403, your Private Integration or OAuth App lacks the scope for that resource. For instance, reading contacts requires contacts.readonly or contacts.write. If you update scopes under Settings > Developers > Private Integrations, generate a new token for the changes to apply.

For more details on querying other sub-account resources, see our guide on finding and using GoHighLevel location ID across API v2.

Frequently Asked Questions

Can I use my Agency API Key in API v2?

No. API v1 Agency keys do not work on v2 endpoints. For agency-level operations (like creating sub-accounts), create an Agency-level Private Integration or build a Marketplace OAuth app with Agency scopes.

Do Private Integration tokens expire?

Private Integration tokens (prefixed with pit-) do not expire automatically unless you revoke or regenerate them in HighLevel. Standard OAuth 2.0 access tokens expire after 24 hours and must be refreshed using your refresh_token.

Is locationId required if my Private Integration was created inside that specific sub-account?

Yes. Even though the Private Integration token belongs to that location, most v2 endpoints still require passing locationId explicitly in the query params or JSON body to enforce multi-tenant isolation.

What is the rate limit for GoHighLevel API v2?

HighLevel limits requests to 100 requests per 10 seconds per location, and 200 requests per 10 seconds per agency across all sub-accounts. Exceeding this returns an HTTP 429 Too Many Requests with a Retry-After header.

all_in_one_marketing_tool