Guide
Bulk Operations on the GoHighLevel API
Which endpoints are safe to run in parallel, which must be sequential, and how to handle 429 responses when importing at volume.
Last updated
Which endpoints are safe to run in parallel?
Thirteen, in our assessment — the per-record contact and custom-object operations, where each call touches one record and no call depends on another's result. These are the ones we have explicitly enabled for concurrent fan-out, and the reasoning transfers to any client doing its own batching:
| Endpoint | Typical use |
|---|---|
contacts/upsert-contact | Create or update by email or phone |
contacts/create-contact | Create only |
contacts/update-contact | Field backfills across many contacts |
contacts/add-tags | Tag many contacts |
contacts/remove-tags | Untag many contacts |
contacts/add-contact-to-workflow | Enrol many contacts in a workflow |
contacts/delete-contact-from-workflow | Remove many from a workflow |
contacts/add-contact-to-campaign | Enrol many contacts in a campaign |
contacts/remove-contact-from-campaign | Remove many from a campaign |
objects/create-object-record | Seed custom-object records |
objects/update-object-record | Update custom-object records |
associations/create-association | Define association types (rarely bulk) |
associations/create-relation | Link record pairs — the CSV case |
What makes them safe is independence, not the HTTP verb. Each call addresses a distinct record, so interleaving them produces the same end state as running them in order. Concurrency here is bounded by rate limiting, not by correctness.
The limits we run in production, as a starting point for your own: 1,000 records per batch, eight concurrent requests by default and sixteen at most, sharing one pooled keep-alive connection so the batch is not paying for a fresh TLS handshake per record.
The three that need explicit confirmation
Three operations are equally parallel-safe in the mechanical sense and should still not be batched casually, because the failure mode is unrecoverable:
| Endpoint | Why it is different |
|---|---|
contacts/delete-contact | Deletes contacts. No undo. |
objects/delete-object-record | Deletes custom-object records. No undo. |
associations/delete-relation | Removes the link between record pairs. No undo. |
In Hylo these refuse a batch unless the caller passes an explicit confirm_destructive flag. The point is not that the flag prevents anything — a caller can always set it — but that deleting 1,000 records requires a different sentence than deleting one, so it cannot happen as a by-product of a mis-scoped filter.
A cheaper pattern for deletes
Tag the matching records first, check the count in the UI, then delete. Splitting “build the filter” from “execute the destruction” converts an irreversible mistake into a reversible one, and costs one extra pass over records you were already iterating.
Why batching should be opt-in, not default
The thirteen endpoints above are an allow-list, and everything else is refused. That inverts the obvious design, and the reasoning is worth stating because it applies equally if you are building your own batching layer.
A generic “run this endpoint N times concurrently” facility is only correct for endpoints whose calls are independent, and plenty are not: anything that reads then writes, anything where order determines the final state, anything with a per-parent sequence. If batching is on by default, every endpoint added to the API is implicitly declared safe by someone who never looked at it — and the failure is quiet, producing a plausible result set with a few records in the wrong state.
Defaulting to refusal makes the opposite true: an endpoint becomes batchable once someone has actually reasoned about it. The cost is that a legitimate case occasionally gets turned down pending review, which is a much better error to hold than the alternative.
A related trap lives in how a batch reports its result. If success is returned as a count rather than a verdict, an all-failed batch of zero successes can read as a completed write to code checking the wrong field — zero is falsy in most languages right up until it is compared against something. Whatever you build, return per-record outcomes plus a boolean that means what it says.
Handling HTTP 429 when importing at volume
At import volumes a 429 is not an error condition — it is the expected steady state of a system running as fast as it is allowed to. Treat it as flow control rather than as a failure.
The property that makes this tractable: a 429 means the request was rejected without being applied. A retry therefore cannot duplicate a record, and is safe for every verb including POST creates. This is exactly what separates a 429 from a mid-flight disconnect, where the write may or may not have landed and a blind retry can duplicate.
What that implies in practice:
- Honour
Retry-Afterwhen it is present — that is the server telling you the answer instead of you guessing at it. - Back off exponentially with a ceiling when it is absent. An unbounded backoff turns a transient limit into a stalled import.
- Retry creates too. The instinct to exclude non-idempotent verbs from retry logic is correct in general and wrong for 429 specifically.
- Do not treat a disconnect the same way. Read-only calls can be retried freely; a write that died in flight needs a read to establish what actually happened.
We have deliberately not published GoHighLevel's specific rate limits here, because we cannot source current numbers to their documentation and a stale limit is worse than none at all. Read the Retry-After header and the response rather than a number from a blog post.
Save round trips before you save concurrency
Concurrency is the second optimisation. The first is not making the call at all, and GoHighLevel offers a lot of that, because several endpoints accept inline what people habitually do in a follow-up request.
Contact creation is the clearest case. A very common sequence is upsert a contact, then call add-tags, then call update-contact for custom fields — three requests per contact. Both tags and customFields are accepted in the upsert body:
Three requests per contact, or one
# One request per contact — tags and custom fields inline
curl -s -X POST "https://services.leadconnectorhq.com/contacts/upsert" \
-H "Authorization: Bearer pit-your-token" \
-H "Version: 2021-07-28" \
-H "Content-Type: application/json" \
-d '{
"locationId": "LOC",
"email": "person@example.com",
"tags": ["imported", "q3-list"],
"customFields": [{"key": "source_list", "field_value": "q3-webinar"}]
}'On a 400-record import that is 1,200 requests reduced to 400 before any concurrency is applied — a bigger win than tuning parallelism, and it removes the partial-state problem where the contact exists but the tagging call failed.
One related choice worth making early: prefer upsert over create for imports. Re-running a create-based import duplicates records; re-running an upsert-based one converges. Imports get re-run more often than anyone plans for.
Associations: the one people get wrong
If your import links records to each other — contacts to properties, contacts to listings, custom objects to one another — there are two similarly named endpoints, and choosing the wrong one produces a confusing failure.
associations/create-associationdefines an association type: the schema saying a contact may be linked to a listing. You call it rarely, usually once per relationship kind during setup.associations/create-relationlinks an actual pair of records. This is what a CSV import calls repeatedly, and the one worth batching.
The names invite the mistake, and an AI assistant reading endpoint names alone reaches for create-association because it sounds like the general case. If you are attaching many contacts to listings from a spreadsheet you want create-relation, batched. Its counterpart, delete-relation, sits in the destructive set above.
More broadly this is the category of problem that stays invisible until it happens: the endpoint name reads correctly, the schema validates, and the call fails for a reason the schema never mentioned. Our endpoint reference carries these distinctions inline, and the comparison page has more examples of the same species.
Common questions
Does the GoHighLevel API have a bulk import endpoint?
Not a general one. Aside from a few endpoints that accept multiple records natively, importing many records means calling a per-record endpoint many times. What varies between integrations is where that fan-out happens — in your code, in an AI model's tool-call loop, or on a server that takes an array.
Is it safe to call GoHighLevel contact endpoints in parallel?
For the per-record contact operations, yes. Creates, upserts, updates, tag changes, and workflow or campaign enrolment are independent per contact, so concurrency creates no ordering hazard. The practical limit is rate limiting rather than correctness.
How should I handle a 429 from the GoHighLevel API?
Retry with exponential backoff, honouring the Retry-After header when present. A 429 means the request was rejected without being applied, so retrying is safe even for creates — unlike a mid-flight disconnect, where you cannot tell whether the write landed.
Can I bulk delete GoHighLevel contacts?
Technically yes, and it is worth treating as a separate class of operation because deletion has no undo. Tagging the records for review first, then deleting once a human has confirmed the count, converts an irreversible mistake into a reversible one.
Keep reading
- Deprecated GoHighLevel API EndpointsCheck your import is not built on an endpoint that is on its way out.
- The Official GoHighLevel MCP Server vs HyloWhere batching happens differs between the two — the numbers side by side.
- POST /contacts/upsert referenceThe endpoint most imports should be built on, with its full schema.