Overview
GHL Tenant Bridge connects GoHighLevel, a CRM, to a Supabase backend for a business that works in the field. It matches storm reports to the properties inside a storm's radius, looks up addresses and parcels, receives GoHighLevel webhooks, and lets a crew on a roof capture photos with no signal and have them arrive later.
I built it by driving the real APIs rather than reading about them. The database and storage are a hosted Supabase project. The Next.js app runs locally, there is no public deployment, and there are no real users.
What it does
- Radius matching in PostGIS. Properties are matched to a storm with
ST_DWithinover a generated geography column with a GiST index, with row-level security applying to the query. - An offline capture queue. Photos are downscaled, stamped with GPS and time at capture, stored on the device and keyed with a UUIDv7 minted at capture, so a retry can never create a duplicate. The flush runs from the page, because iOS has no background path.
- A GoHighLevel webhook receiver. It verifies the signature, applies a replay window, resolves the tenant, stores the event idempotently and dead-letters what it cannot apply.
- Keyless data sources. Live National Weather Service alerts, historical hail reports, the US Census geocoder and county parcel services.
- OAuth install with rotating refresh tokens and the agency-to-location token exchange.
What I measured
The offline test is a real browser run. It drops the connection, captures four photos, checks that the server received nothing, restores the connection and checks that the queue drains to empty and the server's own counter shows the uploads. Checking the server counter matters, because a queue that simply forgets its contents would otherwise pass.
HighLevel's published sunset for its legacy X-WH-Signature header was 2026-09-01, after which webhooks are signed only with an Ed25519 header. The receiver checks both, prefers Ed25519, and fails closed on a legacy signature presented after that date.
Of the test suites I re-ran for this page, the signature suite passes 19 checks, the workflow-auth suite 22, and the offline queue 56. The README lists 13 suites in total, some of which need a local PostGIS container, Playwright or live service access.
What broke
A bounding box is the wrong shape for a radius. The storm hail data accepts only a box, and a box's corners reach about 1.41 times the radius. A property 67 km from a 50 km storm sits inside the box. The fetch uses the box and the match uses ST_DWithin, and I kept the box query in the repo as a negative control so the over-selection is measured. Reverting the coordinate order in the migration, since ST_MakePoint takes longitude first, produces five failures, including a property 40 km away collapsing to 4.75 km.
A write that raised no error and stored nothing. An update or delete filtered out by a row-level security policy returns success with zero rows, where an insert that violates a check raises. A client that treats "it did not throw" as proof would delete its local copy of a record that was never stored. I reproduced it through PostgREST as well as in psql, and the transport now counts the rows that come back.
Code that had never touched the real schema. The Supabase transport upserted a client_uuid column into a table that did not exist. It had unit tests and had never met a real database. Running the migrations on the hosted project exposed it, and two more migrations make it real.
A negative test that proved nothing. Anon is a member of no organisation, so a test that only checks an anonymous caller is refused can pass while a real cross-tenant hole stays open. The suite now seeds two organisations and two users, refuses tenant B on both the table and the storage folder, and runs a positive control first.
A browser API that only half exists. Safari supports directories in the origin private file system but only added createWritable() in version 26, so the obvious availability check passes on an older iPhone and the write then throws. The adapter detects on a real file handle and falls back to IndexedDB, and reads check both so photos queued before a browser update are not lost.
What it does not do
There is no public deployment. The iOS behaviour is cited from WebKit's documentation and has not been reproduced on a device. Webhook subscriptions can only be created in the HighLevel interface, and the workflow webhook action is unsigned. No write was made to a customer's live account.
Tech Stack
Frontend: Next.js, React, TypeScript, a service worker Backend: Next.js route handlers, Supabase (Postgres, PostGIS, row-level security, Storage) Integration: GoHighLevel OAuth and webhooks (Ed25519 signatures), NWS, NCEI SWDI, US Census geocoder Client storage: IndexedDB, origin private file system Testing: Node assertion suites, Python database suites, Playwright