← Back to Blog

How To Build GoHighLevel Workflows With Code (When The API Says You Can't)

GoHighLevel's public API can't create workflows. Your browser can. Here's the token method, the exact build sequence, and every mistake I made getting there.

By · · blog

How To Build GoHighLevel Workflows With Code (When The API Says You Can't)

Grab the full step by step as a PDF: Building GoHighLevel Workflows With Code. Ten pages, free, every command and every mistake.

Last week I rebuilt a client's entire GoHighLevel setup without logging into GoHighLevel.

Two sub accounts. 30,644 contacts. 184 custom fields, around 300 tags, and 22 workflows, all built by script. The manual quote for the same job was five grand.

A few people on Facebook asked how. So here it is, the whole method, including the bits I got wrong. Especially the bits I got wrong, because that's the useful part and nobody ever posts those.

The wall everyone hits

You can do a lot with GoHighLevel's public API. Contacts, custom fields, pipelines, opportunities, tags. All documented, all fine.

Then you go to create a workflow and it 404s.

Read only. So you can list workflows, but you can't build one. It's been an open request on GHL's ideas board for years with hundreds of votes. So agencies buy snapshot packs, or they click. Every sub account, every time, by hand.

Here's the thing though. Your browser creates workflows all day long. When you hit Save in the builder, something is talking to something. It just isn't the API you were reading the docs for.

Two APIs, two tokens

GHL runs a second, internal API at backend.leadconnectorhq.com. That's what the app itself uses. It doesn't take your Private Integration Token. It takes a Firebase session token, in a header called token-id, because GHL authenticates its users through Firebase.

And that's the whole secret. There's no hack, no exploit, no clever bypass. You're making the same request your own browser makes, logged in as yourself, scoped to the accounts you can already see.

But the trap is which token you take.

Most people grab the token-id header straight out of the Network tab. It works, then it dies about an hour later, usually in the middle of a long job. I did that for the first day and spent more time in DevTools than in my editor.

Take the refresh token instead. In DevTools go to Application, then IndexedDB, then firebaseLocalStorageDb. Open the one record inside, expand value, then stsTokenManager, and copy refreshToken. Your script exchanges that for a fresh session token whenever it needs one:

```python FIREBASE_KEY = "AIzaSyB_w3vXmsI7WeQtrIOkjR6xTRVN5uOieiE"

def id_token(): r = requests.post( f"https://securetoken.googleapis.com/v1/token?key={FIREBASE_KEY}", data={"grant_type": "refresh_token", "refresh_token": os.environ["GHL_FB_REFRESH"]}) return r.json()["id_token"] ```

Mine was still working a week later when I went back in to patch a suppression workflow. Overnight jobs stop dying. Treat it like a password, environment variable only, and if it ever ends up somewhere public, log out everywhere and take a new one.

Stop guessing the JSON

This is the tip I'd give if I could only give one, and it isn't really about GoHighLevel.

I needed a branch that checks which pipeline stage an opportunity is in. So I wrote what any sensible person would write. conditionType: "opportunity". conditionSubType: "pipeline_stage". Saved with no error. Opened the workflow in the UI and the condition was blank.

So I could have burned another two hours guessing. Instead I fixed one branch by hand in the builder, then read that workflow's JSON back out through the API. The workflow record holds a fileUrl, and that's where the actual steps live. Fetch it and you're staring at exactly what the builder produced.

`` "conditionType": "opportunities" not "opportunity" "conditionSubType": "pipelineStageId" not "pipeline_stage" "conditionValue": "" a string, not a list ``

Plural. Camel case. One manual fix in the UI taught the entire builder, and every branch after that was right first time.

The general version: when you're working against an undocumented system, make it write the example for you and read it back. Community schema docs are useful but they're second hand. The account in front of you is never wrong.

What I got wrong, in order

PUT wiped three workflows. Create ignores the name you send, so I renamed afterwards with a small PUT carrying just the version and the name. Clean, tidy, and it deleted every step in the workflow. PUT is a full replace, not a patch. I had a client's workflow 11 sitting there with a trigger and an empty canvas, and 12 and 13 completely blank. Fix: send name, steps and settings together, in one request, every time.

I trusted a 200. That's how the empty ones got past me in the first place. The write succeeded. The response was fine. The workflow was hollow. Now every write is followed by a read back that asserts the steps are actually there.

One word blocked publishing for an hour. Publish failed with "invalid value for the Pipeline filter", so naturally I went and checked the pipeline ID. Four times. The pipeline was fine. The trigger condition used the operator is, and GHL's validator only accepts ==. The schema doc I was working from had is on every trigger type. Publishing through the API instead of the UI is what saved me, because the API hands back the validator's actual words rather than a modal.

The validator hates nulls. A root step's parent has to be an empty string, not null. The last step in a chain has to leave next out entirely, not send it as null. You get a 400 saying "Next is invalid" and nothing else.

I built a loop. Wiring the handoff workflow back into onboarding created a perfect circle with re-entry switched on. GHL will run that forever and never complain. Any workflow that writes a tag an earlier workflow listens for needs a guard condition.

The mistake that would have cost the client money

All seventeen workflows were correct. I'd checked every one. Each did exactly what its spec said.

Then I did something I'd never bothered with before. I listed every tag each workflow writes, and every tag each workflow waits for, and put the two lists side by side.

Two things fell out straight away.

The first: the overdue tracking engine, which is the actual thing this business gets paid for, triggered on a tag that nothing in the entire pack ever added. Not a single workflow wrote it. And it would have sat there looking healthy and doing nothing, forever.

The second: the release gate had its logic on the wrong branches. It queued people when sending was off, and did nothing when sending was on. Individually correct, backwards in practice.

An earlier pass caught five more of the same species. A workflow comparing a setting to itself, which is always true. A workflow waiting on tier tags nothing produced. An orphan with no trigger at all. Two handoffs that just stopped.

None of those are visible when you check a workflow on its own. They only show up when you look at the set as a system.

So that's the check I'd hand you: produced tags versus consumed tags, one table, whole account. Anything consumed but never produced is dead. Anything produced but never consumed is waste or a missing branch. Ten minutes.

And no, there's no way round the API cap

A few people asked, so I'll kill it here. The 10,000 calls a day limit is real and I found nothing that beats it.

It's per sub account, not per token, so a second Private Integration Token buys you nothing. It's a rolling 24 hour window rather than a midnight reset, which is why I sat there the next morning still getting 429s and swearing at my screen. Worse, every retry you fire while you're capped pushes the reset further out.

What to do instead: read the x-ratelimit-daily-reset header that comes back with the 429, it gives you the exact countdown, then schedule your script to wake up then and stop poking it. Log the ID of everything you create before you retry anything, because retry storms make duplicates. One import of mine ended up with 6,679 records from 6,555 source rows and needed a dedupe pass afterwards.

And do the maths before you start. 30,000 contacts at two calls each is six days. Better to know that on day one than on day four.

The big one: bulk contact loads shouldn't touch the API at all. GHL's native CSV importer doesn't use your quota. Import contacts and fields by CSV, then spend the API budget on the things the UI genuinely cannot do. Custom objects are the exception, those are API only.

Why any of this matters

And it isn't really about GoHighLevel.

A client build that was quoted as a week of clicking took an afternoon of scripting and a few days of waiting on a rate limit. The workflows are now JSON files in a repo, which means the next account gets them in minutes and I can diff what changed. And the two bugs that would have cost this business its core service without anyone noticing got caught by a ten minute check that no amount of clicking would have found.

That's the pattern. Stop treating the tool as the boundary of what you can do. Look at what the tool's own interface does, and do that.

The full write up, every step, every trap, the code, is here: Building GoHighLevel Workflows With Code. Free, no course attached.

If you want to talk this sort of thing through with people who are actually building it rather than posting about it, come join us in The Autonomous Business on Facebook. No gurus, no pitching, just business owners comparing notes on what's working.

Brewed by Steven, poured by Viktor

About Steven Tann: Steven helps business owners build systems that run themselves using AI. After 10+ years helping 7,000+ businesses and building his own autonomous operations, he's the bloke who actually does it, not just talks about it. Find out more at steventann.com.

Tags: gohighlevel, automation, api, ai ops