All articles
    GoHighLevel

    GoHighLevel API Integration Guide for Developers

    A GoHighLevel API integration authenticates with OAuth 2.0, or a private integration token for single-tenant tools, then calls the v2 REST API at services.leadconnectorhq.com with a Version header and a locationId on every request. Signed webhooks push real-time events back. Rate limits and idempotent upserts decide whether it survives production.

    Syed Husnain Haider Bukhari
    8 min read

    GoHighLevel is easy to demo and awkward to integrate. The CRM is agency-shaped, which means almost every object you touch belongs to a sub-account, tokens are scoped in ways that surprise people on day one, and the v1 API most tutorials still reference is end-of-support.

    I have built against this API for lead intake, AI calling and SMS pipelines, and two-way CRM sync. This guide is the version of the docs I wish existed: what to authenticate with, what the v2 API actually expects, how webhooks behave, and the specific failures that cost me time so they do not cost you any.

    What does a GoHighLevel API integration actually connect to?

    A GoHighLevel API integration connects to a single sub-account, called a location, or to an agency account that holds many locations. Almost every useful endpoint (contacts, opportunities, conversations, calendars, custom fields) is location-scoped, so the first design decision is which level your token lives at.

    That matters more than it sounds. An agency-level token cannot read contacts directly. It exchanges itself for a location access token, and you cache that per location. If you skip this, you get 401s that look like scope problems and are actually hierarchy problems.

    If you are still deciding whether the platform belongs in your stack at all, start with what GoHighLevel is used for before you write a line of integration code. Half the integrations I get asked to fix exist because someone automated around a feature the CRM already ships.

    OAuth or API key: which GoHighLevel authentication should you use?

    Use OAuth 2.0 if your integration will be installed on accounts you do not own. Use a private integration token if you are building one internal tool against your own sub-accounts. The old v1 location API keys are legacy and should not be the foundation of anything new.

    Private integration tokens are effectively static OAuth access tokens issued from inside a sub-account. They are the pragmatic choice for a Zapier-style glue script, an n8n workflow, or an internal FastAPI service. They never expire or refresh on their own, which is convenient right up to the moment one leaks, so treat them like database credentials and rotate them on a schedule you set yourself.

    How the two authentication models compare in practice.

    DimensionOAuth 2.0 appPrivate integration token
    Best forMarketplace or multi-tenant appsOne internal tool or automation
    Install flowUser consents, you receive a code, you exchange itGenerated in the sub-account settings
    Token lifetimeShort-lived access token plus refresh tokenStatic until revoked
    Multi-locationAgency token exchanges for per-location tokensOne token per location, managed by you
    Ops burdenRefresh logic, token storage, reinstall handlingSecret rotation only
    Failure modeSilent refresh loop breaks all clients at onceA leaked token has no expiry to save you

    If you go the OAuth route, store the refresh token, not just the access token, and refresh on a schedule rather than reactively on the first 401. Reactive refresh under concurrency produces a thundering herd where ten workers refresh simultaneously and nine of them end up holding an invalidated token.

    "Pick your auth model based on who owns the account, not on which tutorial you found first."

    What do you need to know about the v2 API before you write code?

    Three things: the base URL is services.leadconnectorhq.com, every request needs a Version header such as 2021-07-28, and most write payloads need an explicit locationId. Miss any one of them and you get an error that does not name the missing piece.

    The Version header is the part people forget. It is not optional and it is not a nicety. HighLevel uses it to pin endpoint behaviour, so omitting it produces failures that look like malformed requests. Set it once at the session level and never think about it again.

    import os
    import requests
    
    BASE = "https://services.leadconnectorhq.com"
    
    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + os.environ["GHL_ACCESS_TOKEN"],
        "Version": "2021-07-28",
        "Accept": "application/json",
        "Content-Type": "application/json",
    })
    
    
    class RateLimited(Exception):
        pass
    
    
    def upsert_contact(location_id: str, lead: dict) -> dict:
        payload = {
            "locationId": location_id,
            "email": lead["email"].strip().lower(),
            "phone": lead.get("phone"),
            "firstName": lead.get("first_name"),
            "source": "website-form",
            "tags": ["inbound", "api-sync"],
        }
    
        res = session.post(BASE + "/contacts/upsert", json=payload, timeout=20)
    
        if res.status_code == 429:
            # Burst budget gone. Back off with jitter, do not retry immediately.
            raise RateLimited(res.headers.get("X-RateLimit-Remaining"))
    
        res.raise_for_status()
        return res.json()["contact"]

    That upsert call is the single most important endpoint in a contact sync. It matches on email or phone within the location and returns the existing record instead of creating a duplicate, which is the difference between a sync you can re-run safely and one you can run exactly once.

    How do you sync contacts and opportunities without creating duplicates?

    Make the write idempotent at the CRM level with upsert, and idempotent at your level with a stored external id mapping. GoHighLevel does not accept an arbitrary idempotency key on writes, so you build that guarantee yourself.

    The sync order that avoids orphaned records and duplicate pipeline entries.

    1. 1Normalise the inbound record first: lowercase the email, convert the phone to E.164, trim whitespace. Dedupe matching is only as good as your normalisation.
    2. 2Upsert the contact and capture the returned contact id. Do this before anything that references a contact.
    3. 3Persist a mapping row in your own database: source system id, GoHighLevel contact id, location id, last synced hash.
    4. 4Compare the hash of the current payload against the stored hash. If nothing changed, skip the write entirely and save your rate budget.
    5. 5Create or update the opportunity with the contact id, the pipeline id and the pipeline stage id. Both ids are location-specific and must be fetched, never hardcoded across accounts.
    6. 6Write custom fields by field id, not by label. Labels are editable by the end user and will drift.
    7. 7Record the outcome, including the GoHighLevel ids, so a replay after a failure updates rather than duplicates.

    Pipelines are the sharp edge. Pipeline and stage ids differ per location, so a multi-tenant integration has to resolve them at install time and cache them. I lost an afternoon early on to a hardcoded stage id that worked in the demo sub-account and silently 422'd everywhere else. If your pipeline structure is still in flux, fix that first with a proper GoHighLevel CRM setup before wiring code to it.

    How do GoHighLevel webhooks work, and how do you verify them?

    Webhooks push events such as ContactCreate, ContactUpdate, OpportunityStatusUpdate and InboundMessage to your endpoint as signed JSON. HighLevel's webhook documentation describes an Ed25519 signature in the X-GHL-Signature header, with a legacy RSA-SHA256 X-WH-Signature header being retired. The guidance is to verify with the Ed25519 key when X-GHL-Signature is present and fall back to the legacy RSA key only during the transition. Verify against the published public key before you trust a single field.

    Delivery is at-least-once. The docs describe retries with exponential backoff and jitter on any non-2xx response, timeout or connection failure, which means your handler will eventually see the same event twice. Payloads carry a webhookId, so store it and drop repeats.

    The handler contract that keeps webhook processing sane.

    • Return 200 fast. Acknowledge receipt, then process asynchronously on a queue.
    • Return non-2xx only for infrastructure failures. An application bug that returns 500 will trigger a retry storm you do not want.
    • Deduplicate on webhookId, not on payload contents. Contents legitimately repeat.
    • Treat the payload as a notification, not as truth. For anything financial or clinical, re-fetch the record by id.
    • Log the raw body before parsing. Signature debugging is impossible without the exact bytes you received.

    One non-obvious behaviour: your own API writes fire webhooks too. If your webhook handler writes back to GoHighLevel, you have built a loop. Tag API-originated writes and short-circuit on them, or you will find out at 3am when a contact update ping-pongs a few thousand times.

    What are the GoHighLevel API rate limits?

    HighLevel documents two limits on the v2 API: a burst limit of 100 requests per 10 seconds and a daily limit of 200,000 requests, both counted per marketplace app per resource (location or company). Every response carries headers telling you where you stand.

    The rate limit headers to read on every response, per HighLevel's API documentation.

    HeaderWhat it tells youWhat to do with it
    X-RateLimit-MaxRequests allowed in the burst intervalSize your concurrency below this
    X-RateLimit-RemainingRequests left in the current intervalThrottle when it drops into single digits
    X-RateLimit-Interval-MillisecondsLength of the burst windowDerive your sleep duration from this
    X-RateLimit-Limit-DailyYour daily ceilingBudget bulk jobs against it
    X-RateLimit-Daily-RemainingRequests left todayPause backfills before it hits zero

    The daily limit is the one that bites during migrations. A 40,000-contact backfill that reads, upserts and then writes custom fields is three calls per contact, which is over half your daily budget before any live traffic. Batch the reads, hash-compare to skip no-op writes, and run backfills overnight in the account's timezone.

    Because limits are per app per location, a multi-tenant integration does not share one global budget. That is genuinely helpful, but it means your throttling has to be keyed per location rather than global, or a single busy account will starve the rest.

    What are the most common GoHighLevel API integration gotchas?

    Most GoHighLevel API integration failures are not exotic. They are the same handful of assumptions carried over from friendlier APIs. Here is the list I now check before shipping anything.

    The failures I see most often on GoHighLevel builds.

    • Missing Version header, producing errors that blame your payload.
    • Hardcoded pipeline, stage or custom field ids that only exist in one location.
    • Custom fields addressed by label instead of id, breaking the day a user renames one.
    • Phone numbers stored inconsistently, so upsert matching silently creates duplicates.
    • Reactive token refresh under concurrency, invalidating tokens for every worker at once.
    • Webhook handlers that write back to the CRM without loop protection.
    • Assuming v1 documentation still applies; v1 is end-of-support and behaves differently.
    • No dead-letter queue, so a malformed webhook drops an event with no trace.

    On the GoHighLevel AI calling and SMS platform I built, the two that mattered most were loop protection and per-location throttling. Voice and SMS events arrive in bursts, and a conversation that triggers a contact update that triggers another webhook will find every weakness in your handler within minutes.

    Should you build the integration in code or in n8n?

    Build it in a workflow tool if the logic is linear and low volume. Write real code once you need per-location token management, backfills, retry semantics or anything stateful. The crossover comes faster than people expect.

    A decision matrix for where the integration should live.

    Requirementn8n or MakeCustom service
    Under a few thousand events per dayGood fitOverkill
    Single location, one directionGood fitOverkill
    Multi-tenant OAuth token storagePainfulRight tool
    Idempotent backfill of 50k recordsPainfulRight tool
    Signature verification on webhooksPossible with a code nodeNative
    Per-location rate limit budgetingNot practicalRight tool

    In practice I run both. A thin n8n layer handles notification fan-out and simple triggers, while a Python service owns tokens, syncs and anything that must be replayable. That split keeps the workflow canvas readable and puts the hard guarantees where they can be tested.

    If you want the integration designed and handed over rather than debugged in production, that is what my GoHighLevel integration work and broader AI automation services cover, mostly for agencies running many sub-accounts at once.

    Key takeaways

    • OAuth 2.0 is for apps installed on accounts you do not own; private integration tokens are for internal tools against your own sub-accounts.
    • Every v2 request needs the Version header and a locationId, and agency tokens must be exchanged for location tokens before reading data.
    • Contact syncs must use the upsert endpoint plus your own external id mapping, because GoHighLevel does not accept an arbitrary idempotency key.
    • Webhooks are signed with Ed25519 and delivered at least once, so verify signatures and deduplicate on webhookId.
    • Rate limits are burst and daily, counted per app per location, and every response returns headers you should throttle against.
    • Pipeline, stage and custom field ids are location-specific and must be resolved at runtime, never hardcoded.

    Frequently asked questions

    What is the difference between GoHighLevel API v1 and v2?
    V2 is the current API, served from services.leadconnectorhq.com, using OAuth 2.0 or private integration tokens and requiring a Version header on every request. V1 used static location API keys and is end-of-support, so it receives no updates or technical support. New integrations should target v2 only.
    Can I use the GoHighLevel API without building a marketplace app?
    Yes. Generate a private integration token inside the sub-account and send it as a bearer token with the Version header. That covers internal scripts, n8n or Make workflows, and single-tenant services. You only need a marketplace OAuth app when other people will install your integration on their own accounts.
    How many API requests does GoHighLevel allow?
    HighLevel documents a burst limit of 100 requests per 10 seconds and a daily limit of 200,000 requests, counted per marketplace app per location or company. Responses include X-RateLimit-Remaining and X-RateLimit-Daily-Remaining headers so you can throttle before hitting a 429. Check the developer docs for current values.
    How do I avoid creating duplicate contacts through the API?
    Use the contacts upsert endpoint rather than create, and normalise email to lowercase and phone to E.164 before sending. Upsert matches on email or phone within the location and returns the existing record. Store the returned contact id in your own database so replays update instead of duplicating.
    How do I verify a GoHighLevel webhook is genuine?
    Verify the signature header against HighLevel's published public key before parsing the body. The current scheme uses an Ed25519 signature in the X-GHL-Signature header, replacing a legacy RSA-SHA256 header. Log the raw request bytes first, because any reserialisation of the JSON will break verification.
    Is a custom GoHighLevel API integration worth it versus using Zapier?
    It is worth it once you need multi-tenant tokens, backfills, replayable syncs or per-location rate limiting. Below a few thousand events a day on a single sub-account, a workflow tool is cheaper and faster to maintain. The trigger for custom code is state, not volume alone.
    Why am I getting 401 errors with a valid GoHighLevel token?
    Usually because the token is at the wrong level of the hierarchy. Agency and company tokens cannot read location-scoped resources such as contacts directly; they must be exchanged for a location access token first. Also confirm the Version header is present and the granted scopes cover the endpoint.
    Do GoHighLevel webhooks fire for changes made through the API?
    Yes. Writes made by your own integration trigger the same webhook events as changes made in the UI. If your handler writes back to the CRM, that creates a loop. Tag API-originated writes or track recently written record ids so the handler can short-circuit its own changes.

    Sources

    Tags:
    GoHighLevelOAuth 2.0WebhooksREST APIPythonn8n
    HB

    Written by Syed Husnain Haider Bukhari

    AI engineer, data scientist, and founder of Revolutionary Technologies LLC. Ships production AI agents, automations, and data platforms for teams in the US, UK, and UAE — including AgentFlow, AI Walay, and ProLeads.

    Get in touch →

    Related pages

    Want this built instead of researched?

    Book a 30-minute scoping call. You get a one-page plan and a fixed-scope quote within 48 hours.

    Start a conversation

    Let's Create a Revolution