Home Services Portfolio Blog Contact Book a 15-min fit call

Short answer: GoHighLevel is great at running your pipeline, but it was never built to be your reporting warehouse. If you want reliable dashboards, historical trend analysis, or the ability to join CRM data with anything outside the platform, you need to move your data into an external database like Supabase. The two things worth exporting first are your Opportunities table and a companion table that records every stage change per contact over time.

If you run an agency on GoHighLevel (GHL), or you pay for a GHL sub-account, you have probably hit the same wall: the numbers you can see inside the platform are not the numbers you can analyze. This guide covers why moving your GHL data into Supabase fixes that, what to extract, and the exact private integration steps you need to do it safely.

Why GoHighLevel alone isn't enough for reporting

GoHighLevel is a CRM and marketing automation platform. Its dashboards are designed for running your pipeline in the moment, not analyzing it over months. That gap shows up the first time a client asks a question the built-in reports can't answer.

Here is what you run into when you lean on GHL's native reporting:

  • No real history. When an opportunity moves from "New Lead" to "Booked Call," the old stage is overwritten. GHL shows you where a deal is now, not how it got there.
  • Limited custom reporting. You can't easily build the exact view a client asks for: conversion rate by stage, average days in each stage, or month-by-month cohorts.
  • Hard to combine with other data. Your ad spend, accounting numbers, and call logs don't live in GHL, and the platform won't let you join them together anyway.
  • Locked into one tool. Your data is only as flexible as the platform it sits inside.

An external database removes those limits. Supabase, an open-source Postgres platform, is a strong choice: you get a real SQL database, a generous free tier, built-in APIs, and serverless Edge Functions you can use to pull data on a schedule. Once your GHL data lives in Supabase, you can build any dashboard, run any query, and keep the history GHL throws away.

The two tables that matter most: opportunities and stage history

You don't need to export everything on day one. Start with the two tables that unlock the most reporting value.

1. The opportunities table (your main table)

This is the core of any GHL pipeline. Each row is a deal: the contact it belongs to, the pipeline it sits in, its current stage, its value, its assigned user, and the timestamps for when it was created and last updated. Mirroring this table into Supabase gives you a clean, queryable snapshot of your whole pipeline that you fully control.

2. The stage-change history table (the one GHL doesn't keep)

This is the table that turns a basic export into something genuinely useful. Every time a contact's opportunity moves from one stage to another, you write a new row: the opportunity ID, the contact ID, the stage it moved from, the stage it moved to, and the exact timestamp. GHL overwrites that information. Your external database keeps it.

With a stage-history table, you can finally answer the questions clients actually pay for:

  • How long does a lead sit in each stage before it moves forward?
  • Where in the pipeline are deals stalling or dying?
  • What is our real stage-to-stage conversion rate this month versus last?
  • Which team members move deals through the pipeline fastest?

You can't get any of these from GHL's native reporting. That's the main reason to build this. It's also the kind of work I do most often for marketing agencies through Agency Data Ops.

Not sure where to start, or short on time to build it yourself? This is the work I do for GoHighLevel agencies and businesses: I extract your GHL data into a clean Supabase warehouse and build the dashboards on top of it. Book a 15-min fit call and I'll map out what your reporting could look like.

How the extraction works: private integrations and a sync function

Moving data from GoHighLevel to Supabase comes down to two pieces: a Private Integration (which gives you read access to the GHL API) and a function (which does the actual pulling and inserting on a schedule).

The database tables

Before the sync runs, create the two tables in Supabase. This is the minimal shape that supports everything above:

-- Main table: the latest snapshot of every opportunity
create table opportunities (
    id               text primary key,
    contact_id       text,
    pipeline_id      text,
    current_stage_id text,
    name             text,
    status           text,
    value            numeric,
    assigned_to      text,
    updated_at       timestamptz
);

-- History table: one row per stage change, kept forever
create table opportunity_stage_history (
    id             bigint generated always as identity primary key,
    opportunity_id text references opportunities(id),
    contact_id     text,
    from_stage_id  text,
    to_stage_id    text,
    changed_at     timestamptz default now()
);

The function

The function is a small piece of code, usually a Supabase Edge Function or a scheduled serverless job, that runs on a timer (say, every 15 minutes, or once a day). On each run it calls the GoHighLevel API, fetches your opportunities, and writes them into Supabase. To build the stage-history table, the function compares each opportunity's current stage against the last stage you stored. Whenever it spots a change, it inserts a new history row. That comparison is what turns a live snapshot into a timeline you can analyze.

Here is a minimal Supabase Edge Function (Deno / TypeScript) that fetches opportunities from the GoHighLevel API and upserts them into Supabase. It uses the read-only Private Integration token from the next step:

// supabase/functions/sync-opportunities/index.ts
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const GHL_TOKEN = Deno.env.get("GHL_PRIVATE_TOKEN")!;       // read-only Private Integration token
const GHL_LOCATION_ID = Deno.env.get("GHL_LOCATION_ID")!;   // your sub-account (location) id

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);

Deno.serve(async () => {
  // 1. Fetch opportunities from the GoHighLevel API
  const res = await fetch(
    `https://services.leadconnectorhq.com/opportunities/search?location_id=${GHL_LOCATION_ID}&limit=100`,
    {
      headers: {
        Authorization: `Bearer ${GHL_TOKEN}`,
        Version: "2021-07-28",            // required GHL API version header
        Accept: "application/json",
      },
    },
  );

  const { opportunities } = await res.json();

  for (const opp of opportunities) {
    // read what we stored last time so we can detect a stage change
    const { data: existing } = await supabase
      .from("opportunities")
      .select("current_stage_id")
      .eq("id", opp.id)
      .maybeSingle();

    // 2. Upsert into the opportunities table (the main table)
    await supabase.from("opportunities").upsert({
      id: opp.id,
      contact_id: opp.contactId,
      pipeline_id: opp.pipelineId,
      current_stage_id: opp.pipelineStageId,
      name: opp.name,
      status: opp.status,
      value: opp.monetaryValue,
      assigned_to: opp.assignedTo,
      updated_at: opp.dateUpdated,
    });

    // 3. If the stage changed, write a row to the stage-history table
    if (existing && existing.current_stage_id !== opp.pipelineStageId) {
      await supabase.from("opportunity_stage_history").insert({
        opportunity_id: opp.id,
        contact_id: opp.contactId,
        from_stage_id: existing.current_stage_id,
        to_stage_id: opp.pipelineStageId,
        changed_at: new Date().toISOString(),
      });
    }
  }

  return new Response(JSON.stringify({ synced: opportunities.length }), {
    headers: { "Content-Type": "application/json" },
  });
});

Schedule it with Supabase Cron (pg_cron) or any external scheduler, and the two tables stay current on their own. The opportunities table always holds the latest snapshot, and opportunity_stage_history grows one row per stage move, which is the history GHL never keeps.

The snippet above skips pagination and error handling to stay readable. For production, page through all results, retry failed API calls, and log each run so a silent failure doesn't leave your dashboards stale.

Step by step: setting up the private integration (read access)

GoHighLevel's Private Integrations are the modern, secure way to give your own tools API access to a sub-account. Here is what the setup takes:

  1. Open the sub-account you want to extract data from in GoHighLevel.
  2. Go to Settings → Private Integrations (labeled "API Keys / Private Integrations" in some accounts).
  3. Click Create New Integration and give it a clear name, like "Supabase Data Sync."
  4. Select the read scopes your sync needs. For this project, enable read access to Opportunities (opportunities.readonly) and Contacts (contacts.readonly). Read scopes are all you need, since the integration only pulls data and never writes back to GHL.
  5. Generate and copy the access token. GHL shows it once, so store it right away.
  6. Save the token as a secret in Supabase. Never hard-code it in your function. The Edge Function reads it from the environment at runtime.
  7. Test the connection with a single API call before you schedule anything, to confirm the token and scopes work.

Keeping the integration read-only is deliberate. It means the sync can never modify or delete anything in your live CRM by accident. It only ever reads.

Frequently asked questions

Can I export GoHighLevel data without coding it myself?

Yes. The setup is a one-time technical build. Plenty of agencies have it done for them so their team can use the dashboards instead of maintaining the pipeline. That's the service I provide.

Does this work per sub-account or across my whole agency?

Both. Each sub-account gets its own Private Integration and read token. The sync function can pull from one account or loop across many into a single Supabase project.

Will syncing to Supabase slow down or affect my GHL account?

No. Read-only API calls don't change anything in GHL, and the sync runs on a schedule in the background.

Why Supabase instead of Google Sheets or Airtable?

Sheets and Airtable break down at scale and can't run real SQL analysis. Supabase is a full Postgres database, so it handles large volumes and complex queries and powers production dashboards without hitting row limits.

How often should the data sync?

For most agencies, every 15 minutes to once an hour is plenty for near real-time dashboards. Historical reporting works fine on a daily sync.

Own your GoHighLevel data

GoHighLevel runs your business day to day, but it shouldn't be the only place your data lives. Once your Opportunities and stage-change history sit in Supabase, you get permanent history and full ownership of the numbers you make decisions on, which is exactly what GHL's native reporting can't give you.

If you'd rather skip the technical build and have a working data warehouse and dashboards handed to you, that's what I do for GoHighLevel businesses every day. You can see a related project in my GHL to Google Sheets workflow case study.

Book a 15-min fit call for a free consultation, or hire me on Upwork to get started right away.

All Articles Book a 15-min fit call

Need Help With a Reporting Workflow?

I build custom dashboards, spreadsheet automation, and data workflows around the tools your team already uses.

Book a 15-min fit call