Server-side pull from HighLevel Forms API using a Private Integration Token (no OAuth app flow). Data lands in ghl_form_submissions only; uts_events is never deleted or overwritten.
Tables
ghl_form_submissions— one row per submission (submission_id+location_id), raw JSON, normalized email/phone, optionalmatched_uts_event_idpointer touts_events.id.ghl_sync_watermark— last run stats per location (ReplacingMergeTree).
Apply DDL under server/migrations/ghl-form-submissions.sql and server/migrations/ghl-sync-watermark.sql, or run server/migrate.js against ClickHouse.
Configuration
Recommended: open Dashboard → GHL leads (/ghl-leads). Enter each sub-account’s Location ID, site domain, and Private Integration Token (Forms scope), then Save credentials and Sync from GHL. Values persist to server/data/ghl_locations.json (Docker: mount ./server/data → /app/data).
Alternatively set GHL_LOCATIONS_JSON to an array of { "locationId", "siteDomain", "privateIntegrationToken" } for each sub-account (overrides the file when non-empty). Other options: GHL_LOCATIONS_JSON_FILE, GHL_LOCATIONS_JSON_B64, or GHL_LOCATION_STORE_PATH to move the dashboard file.
Optional: GHL_MATCH_WINDOW_HOURS (default 48) — window around submission time when matching to uts_events.
Admin API
Requires Authorization: Bearer <ADMIN_MASTER_KEY> (same as other /admin/* routes).
-
GET /admin/ghl/locations-config— masked tokens + storage path (no secrets in full). -
POST /admin/ghl/locations-config— body{ "locations": [ { "locationId", "siteDomain", "privateIntegrationToken" } ] }. Use"__KEEP_EXISTING__"or omit token to keep the prior key for thatlocationId. -
GET /admin/ghl/activity-log— recent save/sync events (in-memory). -
POST /admin/ghl/sync-form-submissions
Body (JSON, optional):startDate,endDate,formId,locationId(omit to sync all configured locations),rematchOnly(boolean — skip GHL fetch; only run matcher on rows with no match). -
GET /admin/ghl/sync-form-submissions/status— recent watermark rows.
Matching order
For each GHL row without matched_uts_event_id, the engine tries, within the time window and site_domain:
- email —
lead_email - phone — last 10 digits of
lead_phone - gclid — from GHL page URL vs
uts_events.gclid - ip — weak; only if submitter IP exists on the payload and equals
uts_events.ip_address
Only ghl_form_submissions match columns are updated; uts_events payloads are unchanged.