The version I get called in to fix looks like this. Someone connected Power BI to GoHighLevel, built a decent-looking report, and rolled it out to the client book. A couple of months later the refresh fails because a custom field got renamed, and nobody notices for a fortnight, since the report still opens and still shows numbers. Then a client asks how their pipeline looked at the end of last quarter, and the answer is that this report can only ever show today.
That build wasn't badly made. It was missing a layer.
A GoHighLevel to Power BI integration drawn as two boxes and an arrow will disappoint you, because two of the three jobs involved have nowhere to live.
Why not just point Power BI at GoHighLevel?
Because a direct connection asks one tool to do four jobs, and it can only do one of them well.
Power BI is a modelling and presentation layer. It isn't a scheduler with retry logic, it isn't a place to keep history, and it isn't where you want API credentials for fifteen sub-accounts. Wire it straight to the CRM and all of that gets absorbed into a dataset refresh, which then becomes your single point of failure. Add clients and the refresh window stops being big enough. One upstream change fails everything rather than one table.
History never accumulates either. GoHighLevel stores current state, so each refresh overwrites your only copy of yesterday with today. That property alone kills stage velocity, cohort reporting, and any attempt to reproduce a number you already sent, which I've written up in what GoHighLevel overwrites.
Definitions scatter too, because with no modelling layer "qualified lead" gets defined inside a Power Query step and the next report defines it slightly differently.
There's a plainer obstacle as well. Power BI has no first-party GoHighLevel connector, so the direct route means either a third-party connector you don't control or a custom API query maintained inside the report file. Both bury integration logic where nobody looks when it breaks.
What is each layer responsible for?
Three layers, three jobs, each testable on its own.
Layer one gets data out of GoHighLevel on a schedule and lands it somewhere durable, unchanged. Layer two decides what that data means: how records relate, what a stage change is, which client a row belongs to, what each metric counts. Layer three reads a finished model and draws it.
The split pays off because a failure stays inside the layer that caused it. Broken ingestion leaves timestamped gaps rather than a blank dashboard, and a changed definition means editing one model instead of eleven reports.
Layer 1: what does ingestion have to guarantee?
That data leaves GoHighLevel on a schedule and arrives intact, without needing a person at a desk on Monday morning.
Four object types cover most agency reporting. Contacts, the only record counting humans. Opportunities, which carry pipeline and revenue. Appointments, for the booked-versus-showed numbers agencies sell. And stage-change events, which the platform doesn't keep for you.
That last one is why ingestion needs two mechanisms. A scheduled API pull gives you a complete table on a cadence you choose, but it can't see a deal that moved twice between runs. A webhook fired from a workflow catches every move as it happens, and fails silently when your endpoint is down. They fail in opposite directions, so running both lets each check the other. That reconciliation is the step people skip, and it's what makes the data defensible.
Idempotent upserts, or why re-running has to be safe
The most important property in this layer: running the same job twice must not create the same row twice.
Every pull writes on a natural key, the GoHighLevel record ID, and updates the row if it already exists. Get that right and a failed run is fixed by running it again, and a duplicate webhook delivery costs nothing. Get it wrong and your opportunity count inflates every time somebody retries a job, the kind of error that surfaces months later in front of a client. Event tables are the deliberate exception, since a stage change that happened is a fact nobody should be editing.
API limits, paging, and backfills
Any API of this kind caps how much you can pull and how fast, so ingestion has to page through results, respect a rate limit, and resume rather than restart when it stops halfway. Retrofitting that into a job which assumed one clean request is a rewrite, so design for it at the start.
Backfills deserve their own schedule. Onboarding a client means loading their existing records once, slowly, while the incremental job carries on. Sharing a schedule between the two is how you hit a rate limit at 9am and lose that day's data. And a backfill only recovers records, never the stages those deals moved through last year, because the platform never stored them.
The mechanics of this half are already published: a read-only private integration, a scheduled function that pages through results, raw tables nobody edits by hand, in Export GoHighLevel Data to Supabase. That build is the ingestion half of this architecture.
Layer 2: what belongs in Supabase, and what doesn't?
Supabase is Postgres with the surrounding parts already built, which is why I reach for it on agency work: scheduled functions, an auth model, and row-level security I don't have to invent. Any managed Postgres does this job, and none of the reasoning below changes if you're on something else. What matters is the split inside it.
Raw and modelled are two different schemas
The raw schema is a copy of what GoHighLevel gave you, warts included. Original field names, original values, a load timestamp on every row, and a rule that nothing here gets edited by hand or read by a report.
The modelled schema is where you make decisions. Which stages count as won. What a qualified lead is. How a contact ties to an opportunity ties to a client. Whether refunds net off. Each is a business decision, written down once, here.
Keeping them apart is what makes the system survivable. When a definition turns out to be wrong, you rebuild the model from raw data you still have.
The stage-history table is the reason to bother
If you build one thing in the modelled schema, build the stage-change event table: one row per transition, carrying the opportunity, the stage it left, the stage it entered, and when.
That table turns a CRM copy into a reporting system. Time in stage becomes a subtraction. Stage-to-stage conversion gets a denominator made of deals that genuinely entered the stage rather than the ones sitting in it now. "What did the pipeline look like on 31 March" becomes a query instead of an apology.
Stage names are configuration, though, so somebody will rename Discovery to Qualified next spring. A stage dimension with its own identifier, holding the name as an attribute rather than as the key, keeps last year's reports readable afterwards.
Per-client separation for multi-tenant agencies
Every modelled table carries a client identifier, taken from the sub-account rather than from anything a human types.
The alternative is a database per client. Fine at three clients, a second job at fifteen, because every schema change now happens fifteen times. One model with a client dimension gives you per-client reporting and agency roll-up from the same tables, which is the argument I made about account structure in multi-location reporting. Row-level security is where Supabase earns its keep: the policy deciding who sees which client's rows sits next to the data rather than in every tool that connects, so a misconfigured report still gets refused.
The least technical part of this layer carries the most commercial value. Cost per booked call, qualified lead, won revenue, show rate: each gets defined once, here, and every report inherits it. Skip that and you spend a year in meetings about why two dashboards disagree.
Layer 3: what should Power BI actually be allowed to do?
Read the modelled views and draw them.
That restriction is the design. Power BI connects to views in Supabase through a read-only role, and those views act as a contract: no report touches raw tables, no transformation exists in one file only. If a number needs changing, it changes in the database and every report agrees the next morning.
You get one report template reused per client, because parameterising the client identifier turns adding a client into a configuration step rather than a build. Fifteen bespoke reports is fifteen things to edit when somebody asks for one more chart. The scheduled refresh gets boring too, since it reads a database that's already correct.
My bias, stated. I hold Microsoft's PL-300 (Power BI Data Analyst Associate), so I'm the person you'd expect to put more logic into Power BI, not less. Knowing what a semantic model costs to maintain is exactly why I push definitions down into the database instead.
What does one opportunity look like moving through all three layers?
An illustration rather than a client account, but the path is real.
At 14:12 on a Tuesday, an account manager drags deal 4471 from Booked Call to Proposal. Inside GoHighLevel one field is replaced and the old value is gone. Seconds later a workflow fires a webhook, and the ingestion endpoint appends one row to the raw event table: opportunity 4471, from Booked Call, to Proposal, timestamp, delivery ID. Nothing is updated, so a duplicate delivery gets dropped later on that ID.
At 02:00 the scheduled pull upserts the full opportunity record on its GoHighLevel ID, updating the existing row rather than duplicating it. Snapshot and event now agree that 4471 sits in Proposal, which is the reconciliation check. Had they disagreed, a webhook went missing and you know on Wednesday instead of during a dispute in November.
At 02:10 the transform runs. Deal 4471 lands in the modelled fact table with a client key from its sub-account and a stage key from the stage dimension, so July's rename won't disturb it. The transition writes into the stage-history table, and time in Booked Call becomes computable.
At 06:00 Power BI refreshes against the modelled views, and that client's template report picks up one more deal in Proposal. The following March, when the client asks what the pipeline looked like at the end of Q4, that's a query, because every state 4471 passed through was written down as it happened.
What does this cost to run, and when should you not build it?
This is a real build with running costs, and most agencies who ask me about it shouldn't start one yet.
The costs are a managed Postgres plan, compute for the scheduled jobs, Power BI licences, and the maintenance time nobody budgets for, which is the real number. Sync jobs fail, APIs change, schemas drift, and somebody has to notice. An agency that builds this without naming a maintainer ends up with an expensive stale copy of its data, which is worse than no copy, because people trust it.
With a small book of clients and reporting thrown in as a courtesy, the lighter stacks win, and I've laid that comparison out in Excel, Power BI, or a warehouse. A scheduled pull into a well-built sheet with an append-only snapshot tab handles most of what gets described to me as needing a warehouse.
What tips it is some combination of enough clients that per-client maintenance costs more than one shared model, history the platform can't give you, several systems that have to reconcile, and reporting accuracy being a deliverable someone pays for. When two or three are true, this is the cheaper option. When one is true, fix that one thing where you already are, and treat stage-change capture as the exception at any size, since history nobody wrote down can't be recovered later. Whether native reporting is failing you at all is a separate question, covered in GoHighLevel reporting limitations.
Frequently asked questions
Can Power BI connect directly to GoHighLevel?
Only through a third-party connector or a custom API query built inside the report, since there's no first-party connector. Both work for a demo. Both put integration logic somewhere nobody maintains, give you no history, and make one refresh responsible for extraction and modelling.
Why use Supabase instead of connecting Power BI to the GoHighLevel API?
The database does three things the API can't. It keeps history the platform overwrites, so last quarter still exists. It holds your metric definitions in one place instead of inside each report. And it absorbs paging and rate limits on a schedule you control rather than inside a refresh window. Any managed Postgres does this; Supabase bundles scheduled functions and row-level security.
How often should GoHighLevel data sync to a warehouse?
Match the cadence to how the report gets consumed rather than to what's technically possible. Nightly covers most agency reporting and has a useful property: a failed run leaves a visible gap you can rerun. Event capture is the separate case, caught as it happens.
Do I need a developer to maintain a GoHighLevel to Power BI pipeline?
You need a named owner, which isn't quite the same thing. The recurring work is checking that scheduled jobs ran, that the event log and the snapshot agree, and that an upstream field change hasn't broken a transform. That's a monitoring habit rather than development. What you can't do is have nobody own it, because unowned pipelines fail quietly while the reports keep opening.
Find out whether you need this architecture
Plenty of the agencies who ask me for this diagram need two of the three layers and a decision they've been putting off. Some need all of it and have needed it for a year.
The agency CRM and reporting diagnostic is a paid, bounded first engagement that settles which. I map what each of your systems holds, where the join keys are, which reports you currently send couldn't be reproduced, and whether the answer is this architecture, a lighter stack, or capturing stage history today and revisiting in six months. You get that architecture review whether or not the build happens with me.
See how I work with performance-marketing agencies on CRM and reporting data, or start from the contact page.
About the author. Ahmed Abdelkhalek is a Data Automation and Reporting Consultant and the founder of ChromiumData, a founder-led consultancy. He works directly with marketing agencies and operations teams from diagnosis through delivery, mostly at the unglamorous end of reporting: CRM data that won't reconcile, pipelines that break quietly a month after launch, dashboards nobody trusts. He holds Microsoft's PL-300 (Power BI Data Analyst Associate) and the AWS Certified Solutions Architect (Associate) certification. More at Ahmed Abdelkhalek.