How to sync GoHighLevel contacts to Supabase in real time

    A multi-tenant mirror of your GHL contacts in Postgres — webhook upsert with an ordering guard, RLS tenant isolation, Realtime push, and safe two-way write-back.

    Last updated 2026-09-03~7 min read
    SupabaseGoHighLeveln8nRealtimeRLS

    Skip the wiring. Download the n8n workflow JSON — GHL contact webhook, immediate 200, retry dedupe, Location→org_id mapping, and an ordering-guarded upsert into Supabase. Import it and point it at your project.

    The pattern

    You want a Supabase table that mirrors your GoHighLevel contacts in near real time, so your own app (dashboard, portal, analytics) reads from Postgres instead of hammering the GHL API — and so multiple agency tenants each see only their own contacts. The shape is: GHL webhook in → dedupe → upsert into Supabase → Realtime pushes the change to every subscribed client. Optionally, writes in your app flow back to GHL so the CRM stays authoritative.

    The two things that make this hard aren't the sync — they're tenant isolation (agency A must never see agency B's contacts) and ordering (a stale webhook must not overwrite newer data). Both are solved below.

    Prerequisites

    • A Supabase project with Realtime enabled
    • n8n reachable on a public URL to receive GHL webhooks
    • A GHL Private Integration token scoped to contacts.readonly / contacts.write
    • A tenant model — each agency/sub-account maps to an org_id

    Step 1: Table + RLS for multi-tenant

    Give every row an org_id and a ghl_contact_id, and make the pair unique so upserts are idempotent. Carry an updated_at from GHL for ordering.

    create table contacts (
      id            uuid primary key default gen_random_uuid(),
      org_id        uuid not null,
      ghl_contact_id text not null,
      name          text,
      email         text,
      phone         text,
      ghl_updated_at timestamptz,
      synced_at     timestamptz default now(),
      unique (org_id, ghl_contact_id)
    );
    alter table contacts enable row level security;
    
    create policy tenant_read on contacts
      for select using (org_id = (auth.jwt() ->> 'org_id')::uuid);

    That policy is what makes the mirror safe to expose to clients — a tenant can only select its own rows. The full policy set (insert/update/delete, the service-role bypass, common holes) is in Supabase RLS patterns for multi-tenant agencies.

    Step 2: Webhook receiver — dedupe then upsert

    In n8n, receive the GHL contact webhook, respond 200 immediately, drop retries, and upsert on (org_id, ghl_contact_id). Guard the ordering: only overwrite if the incoming updated_at is newer than the stored one, so a delayed retry can't clobber fresher data.

    insert into contacts (org_id, ghl_contact_id, name, email, phone, ghl_updated_at)
    values ($1, $2, $3, $4, $5, $6)
    on conflict (org_id, ghl_contact_id) do update set
      name = excluded.name, email = excluded.email,
      phone = excluded.phone, ghl_updated_at = excluded.ghl_updated_at,
      synced_at = now()
    where excluded.ghl_updated_at >= contacts.ghl_updated_at;  -- ordering guard

    Step 3: Realtime subscription on the client

    With RLS in place, subscribe to the table from your app. Supabase Realtime respects RLS, so each client only receives changes to rows it's allowed to see — tenant isolation holds on the live channel, not just on reads.

    supabase.channel('contacts')
      .on('postgres_changes',
          { event: '*', schema: 'public', table: 'contacts' },
          (payload) => applyChange(payload))
      .subscribe();

    Step 4: Two-way write-back to GHL

    When your app edits a contact, write to Supabase and PATCH it back to GHL so the CRM stays the source of truth. To avoid an echo loop (your write-back triggers a GHL webhook that re-updates Supabase), tag app-originated writes and skip the inbound webhook when the incoming updated_at matches what you just sent.

    Common variations / gotchas

    1. Tenant isolation on the write path. RLS protects reads and Realtime, but your n8n upsert runs with the service role and bypasses RLS — so the org_id must be derived from the webhook's Location, never trusted from the payload body. Map Location → org_id server-side.

    2. Replay and ordering. GHL can deliver webhooks out of order under load. The where excluded.ghl_updated_at >= contacts.ghl_updated_at guard is what prevents an older event from overwriting newer data — don't drop it.

    3. Delete propagation. Decide explicitly: a contact deleted in GHL should either soft-delete (a deleted_at column) or hard-delete in Supabase. A missing delete handler leaves ghost contacts in the mirror.

    4. Initial backfill. Webhooks only carry changes from now on. Do a one-time paginated pull of existing GHL contacts to seed the table, minding the GHL API rate limits during the backfill.

    Browse 4,900+ ready-to-import workflow templates →

    Need this workflow customized or debugged? Book a 30-min scope call →

    Stuck configuring this workflow?

    I build and fix n8n workflows for agencies and SaaS teams. 30-minute scope call — bring the problem, leave with an estimate.

    Related pages

    Related workflows