A small, typed, dependency-free client for the GoHighLevel API v2, and a local mock server that reproduces the parts of the API that bite.
The GHL API is easy to call and easy to call wrong. Most of its failure modes do not raise errors: a write answers 200 and is ignored, a search misses a contact you created a second ago, a name lookup quietly returns the wrong workflow. A client that only wraps fetch passes every happy-path test and still corrupts data in production.
Each one has a test, most of them against the mock in mock/server.ts.
positionon a custom field only applies when it is the only key in the body. Send{ name, position }and you get 200, a new name, and the old position.customFields.update()splits the patch and sends{ position }on its own.- The search index trails writes.
POST /contacts/searchcan miss a contact you just created or tagged.contacts.get(id)reads the record and is current. When the next step really depends on search,contacts.waitForSearch()polls until a predicate holds, with a deadline that counts the time spent searching, not just the time spent sleeping. - A short page is not the last page. The server can cap the page size below what you asked for.
searchAll()follows thesearchAftercursor until a page comes back empty or without one, never stopping on page length, and fails loudly if a page hands back the same cursor as the page before it. - Adding a tag the contact already has is a no-op. Success, no change, and no "tag added" trigger. To re-fire a trigger, remove the tag first (the companion repo
ghl-dialer-queue-swapis built around this). - Workflow names are not unique. A draft and a published workflow can share a name.
workflows.findByName()matches exactly and throws on two matches, listing ids and statuses, instead of picking one. - Custom fields are addressed by key, never by label. Labels are display text and get renamed.
contacts.setCustomFields()resolves keys to ids before writing; a typo or a label fails before any write, and a label gets a hint with the right key. PUT /users/{id}gets{ permissions }and nothing else. The user read model carries fields the public API does not let you set, so writing back a whole user object sends things you did not mean to.users.setPermissions()merges the current permissions locally (correct whether GHL merges or replaces), sends only that key, and reads the user back to report anything that did not apply.- Not every failure is safe to retry. A 429 means the request was not processed, so it is retried within the retry budget, honoring
Retry-After. ARetry-Afterlonger thanmaxMsfails immediately, with the server's value in the error, instead of being slept in full or retried early. A 5xx or a dropped connection, including one cut off after the headers, may have been processed, so it is retried only for idempotent calls. Workflow enrollment is not one of them, and neither is an upsert with no email or phone, since GHL has nothing to match a repeat against. - Two kinds of token, and only one of them expires. A private integration token is long-lived; an OAuth access token runs out and has to be refreshed. A long batch that starts with a few minutes of token left dies halfway and leaves the location half-updated. Pass
tokenExpiresAtfor an expiring token and run batches throughrunBatch(), which refuses to start (before touching a single item) when the token has less thanminTokenMinutesleft.
import { createGhlClient } from "ghl-api-client";
const ghl = createGhlClient({ token: process.env.GHL_API_TOKEN!, locationId: process.env.GHL_LOCATION_ID! });
const { contact } = await ghl.contacts.upsert({ phone: "+15554070001", firstName: "Dana" });
await ghl.contacts.addTags(contact.id, ["dialer-queue-avery"]);
await ghl.contacts.setCustomFields(contact.id, { best_time_to_call: "after 3pm" });
for await (const c of ghl.contacts.searchAll({
filters: [{ field: "tags", operator: "contains", value: "dialer-queue-avery" }],
})) {
console.log(c.id);
}
const nurture = await ghl.workflows.findByName("New Lead Nurture"); // throws if ambiguous
await ghl.workflows.enroll(contact.id, nurture.id);| area | methods |
|---|---|
| contacts | upsert, get, update, delete, addTags, removeTags, search, searchAll, waitForSearch, setCustomFields |
| custom fields | list (cached), resolve, update |
| workflows | list, findByName, enroll, unenroll |
| tags | list |
| users | get, setPermissions |
| token and batches | token.kind, token.minutesLeft, runBatch |
Transport, in src/http.ts: every request sends Authorization: Bearer and the Version header (GHL rejects requests without it), targets services.leadconnectorhq.com, and goes through one retry policy: exponential backoff with full jitter, capped at maxMs; when the server sends Retry-After, it is honored exactly up to maxMs, and beyond that the request fails immediately instead of sleeping or retrying early. stats exposes request and retry counts.
contacts.upsert() and contacts.update() refuse a tags key at runtime too, so JavaScript callers cannot replace a contact's tags through them by accident.
Needs Node 22.18 or newer, which runs TypeScript directly. No build step and no runtime dependencies.
npm test # the whole suite against the local mock, no network, no token
npm run demo # traps 1, 2 and 4 to 8 against the mock (3 and 9 are covered by the tests)npm install is only needed for npm run typecheck. With GHL_API_TOKEN and GHL_LOCATION_ID set, npm run demo instead does a read-only pass over a real location: counts tags, workflows and custom fields, and flags duplicate workflow names.
The wait and the request count depend on timing; the rest is the same on every run:
- upsert: created ct_mock_0001
- adding the same tag twice: success both times, tags unchanged [ 'dialer-queue-avery' ]
- search right after the write finds 0
- get by id already shows tags [ 'dialer-queue-avery' ]
- search caught up after ~761 ms
- custom field written by key [ { id: 'cf_demo_0002', value: 'after 3pm' } ]
- by label instead unknown custom field key: "Best Time To Call" is a label; its key is "contact.best_time_to_call"
- rename + reorder in one call: name "Last Call Result", position 10
- workflow by name 2 workflows are named "New Lead Nurture" (wf_demo_0003 draft, wf_demo_0004 published); use the id
- permissions PUT body keys: permissions, not applied: []
- requests: 27, retried after 429: 6
import { startMockGhl } from "ghl-api-client/mock";
const server = await startMockGhl({ indexLagMs: 200, rateLimit: { rate: 0.3, seed: 7 } });
const ghl = createGhlClient({ token: "test-token", locationId: "loc_demo_0001", baseUrl: server.url });
// ... server.requests holds every request with its parsed body
await server.close();It listens on 127.0.0.1 on a random port, checks the token and the Version header, answers a seeded fraction of requests with 429, delays search visibility by indexLagMs, ignores position when it is not alone, and treats a repeated tag as a no-op. It covers only the endpoints this client calls and is not a faithful clone of GHL.
- Search filters. Today the client passes filters through as the public Search Contacts docs describe them (
{ field, operator, value }and{ group, filters }), and the mock implements onlytagswithcontainsand plain equality on top-level fields. To confirm: the exact operators your account accepts for each field. - Auth. Today the client takes a ready token (a private integration token or an OAuth access token) and only checks its remaining life. The OAuth flow and token refresh are out of scope.
GET /users/{id}envelope. Today the client accepts the user either bare or wrapped inuser. To confirm: which of the two your account returns, then drop the other branch.
Pattern extracted from work I did on an outbound sales operation; the code here is written from scratch with synthetic data.