# How to send leads to GoHighLevel through the API

> Send website leads into a GoHighLevel sub-account with a Private Integration token and the contacts upsert endpoint, without making duplicate contacts.

Source: https://roughfigure.com/blog/send-leads-to-gohighlevel-api (updated 2026-11-11)

To send leads to GoHighLevel (HighLevel) through the API, create a **Private Integration token** in the sub-account with the `contacts.write` scope, then `POST` each lead to `https://services.leadconnectorhq.com/contacts/upsert` with the sub-account’s **Location ID**. Upsert adds the contact if they’re new and updates them if they already exist, so a customer who enquires twice doesn’t become two contacts.

That’s the short version. Below is each step, the exact request, and the mistakes that trip people up.

## What you need before you start

- **Access to the sub-account** (the location) that leads should land in. Agency-level keys aren’t needed for this.
- **A Private Integration token.** In the sub-account, open **Settings → Private Integrations**, choose **Create new Integration**, name it after your website or estimator, and tick only the **contacts.write** scope. Copy the token straight away: HighLevel won’t show it again.
- **The Location ID**, from the sub-account’s **Settings → Business Profile**.

Give the token the fewest scopes that do the job. A token that can only write contacts can’t read your conversations, payments or calendars if it ever leaks. There’s more on this in [how to keep CRM API keys safe](https://roughfigure.com/blog/keep-crm-api-keys-safe).

## The request

```http
POST https://services.leadconnectorhq.com/contacts/upsert
Authorization: Bearer <your Private Integration token>
Version: v3
Content-Type: application/json

{
  "locationId": "<your Location ID>",
  "firstName": "Sophie",
  "lastName": "Turner",
  "email": "sophie.turner@mail.test",
  "phone": "+447700900123",
  "postalCode": "BS6 5QA",
  "source": "Website estimator",
  "tags": ["website-lead", "fencing"]
}
```

A few details matter:

- **The `Version` header is required.** Without it the request is refused. The current contacts upsert reference lists `v3`; check the version switcher in HighLevel’s API docs if you’re maintaining older code that uses a date version.
- **`locationId` is the only required field**, but send an email or phone, or there’s nothing to match a returning customer on.
- **Phone numbers work best in international format** (`+44…`, `+1…`), so HighLevel’s SMS and calls work without editing.
- **`source` and `tags`** are what let you build a workflow that fires only for website leads.

The reply tells you whether the contact was new:

```json
{ "new": true, "contact": { "id": "…", "locationId": "…" }, "traceId": "…" }
```

Keep the contact `id` if you want to add notes or opportunities to the same person later.

## Duplicates: how upsert decides

Upsert follows the sub-account’s **Allow Duplicate Contact** setting. With duplicates off (the usual choice), HighLevel matches on email and phone and updates the person it finds. If your sub-account allows duplicates, every enquiry can make a new contact. [Why duplicates happen and how upsert stops them](https://roughfigure.com/blog/stop-duplicate-contacts-in-your-crm) goes into this in more depth.

## Handling errors and retries

| Reply      | What it means                                                    | What to do                       |
| ---------- | ---------------------------------------------------------------- | -------------------------------- |
| 401        | The token is wrong, revoked or rotated                           | Make a new token and update it   |
| 403        | The token is missing `contacts.write` or is for another location | Check scopes and the Location ID |
| 422        | A field is invalid, often a badly formatted phone or email       | Fix the data, don’t retry as-is  |
| 429 or 5xx | Rate limit or a HighLevel outage                                 | Retry later, with a growing wait |

Never drop a lead because the CRM was down. Save it first, then send it, and retry for a few hours on 429 and 5xx replies. Because upsert matches on email and phone, retrying is safe: it won’t make a second contact.

## Starting a workflow when the lead arrives

Once leads arrive with a tag, open **Automation → Workflows** and use the **Contact Tag** trigger (tag added: `website-lead`). From there you can text the customer, assign them to someone and create an opportunity in your pipeline. Replying quickly is most of the battle: see [speed to lead](https://roughfigure.com/blog/speed-to-lead-for-trades).

## Doing it in RoughFigure

In RoughFigure we’ve built the HighLevel connection for you. Open **Integrations → Send leads to your CRM**, choose **HighLevel**, paste your Private Integration token and Location ID, and click **Connect**. That one click is all it takes: from then on every lead from your estimator arrives in the sub-account as a contact, with their name, email, phone, postcode and “RoughFigure” as the source, updated rather than duplicated if they’re already there.

Click **Send a test lead** to see “RoughFigure connection check” arrive, and if HighLevel is ever down we keep retrying for about four hours. [See the HighLevel integration](https://roughfigure.com/integrations/highlevel) or read [how to connect your CRM](https://roughfigure.com/help/connect-your-crm).

## Questions

### Do I need a HighLevel marketplace app to use the API?

No. For one sub-account sending leads into itself, a Private Integration token is enough. A marketplace app with OAuth is for software that many agencies install.

### Should I use an agency token or a sub-account token?

A sub-account token. It can only reach that one location, which is what you want for a website form.

### How often should I rotate the token?

HighLevel suggests rotating Private Integration tokens regularly, every 90 days or so. Make the new token, update your website or RoughFigure, then delete the old one.

### Can I create an opportunity at the same time?

Yes, with a second call to the opportunities endpoint using the contact ID you got back. Many businesses find a workflow triggered by a tag simpler to change later.
