Skip to content

Lead Engine (wiring)

Owner
IT-CRM
Updated
2026-09-07
Status
Capture loop live and unattended · outbound blocked on A2P + sending domain
Scope
GoHighLevel · ATG Central CRM · ghl-bridge

The permanent machinery under the Water Truck Go-To-Market campaign. Strategy changes; this does not.

Summary

GoHighLevel owns front-of-funnel engagement. The ATG Central CRM stays the system of record for identity. Two services bridge them, and the whole capture path runs unattended.

site form → existing lead API → ATG Central CRM
                        sync_out.py poller (every 5 min)
                     GHL contact + opportunity on the brand's board
                        "CRM Sync" workflow (webhook)
                   ghl-bridge → back into the CRM (patch, not duplicate)

Why the CRM stays the record

Five services already publish into it; dedupe on canonical email and E.164 phone is implemented; it carries qb_customer_id, the link to the books. It is also the only store where all three entities' contacts legitimately sit together — GHL sub-accounts are deliberately siloed, which is wanted publicly and would destroy the cross-entity view if GHL were the record.

What exists in GoHighLevel

Agency Sovran Group; single location Superior Equipment, Phoenix.

Pipelines

Pipeline Stages
SWTR — Superior Water Truck Rental Inquiry → Qualified → Quoted → Agreement Out → Deposit Paid → On Rent → Month 3 · Buy Review → Off Rent
SEQ — Superior Truck Sales Inquiry → Discovery → Spec'd → Quoted → Negotiation → Build Slot → Delivered
SWTP — Superior Water Truck Parts Inquiry → Part Identified → Quoted → Ordered → Fulfilled → Reorder Window

Month 3 · Buy Review is load-bearing, not decoration: a renter three months in is paying for a truck without owning one. It is a stage specifically so nobody has to remember to look.

GHL's stock demo pipeline and its four "(Example)" opportunities showing a fictional $10.92K were deleted 2026-09-06.

Custom fields — 22, all model=contact

  • Bridge join keys: crm_contact_id (C-####), crm_lead_id (L-####). These are how the bridge reconciles a GHL record to a warehouse row without guessing — and they are what prevents the feedback loop below.
  • Attribution, mirroring the leads table: lead_domain, form_type, lead_source_type, source_page, landing_page, referrer, first_touch_{source,medium,campaign}, last_touch_{source,medium,campaign}, cta_location, stock_no, product_title.
  • SWTR qualification: rental_start_date, rental_return_date (DATE), tank_capacity (2,000 / 4,000 / Other), job_site_city.
  • Pre-existing: business_unit (checkbox, all six brands) — created 2026-09-01 before this build, and evidence of an earlier single-location multi-brand intent.

Contacts and opportunities

24 contacts imported from the CRM, tagged by brand, and 22 opportunities seeded onto the boards (SEQ 9, SWTP 12, SWTR 1) so the pipelines opened with real work rather than empty columns.

🔴 Gap — the owned audience is 22, not 56

Of 45 in-scope CRM contacts: 22 marketable, 3 internal staff, 18 test/smoke rows, 2 form spam. The warehouse headline was inflated ~40% by rows like smoke+…@example.com and WIRE TEST - WDM (delete).

Test and internal rows were never imported — feeding smoke tests into a marketing system is how a fake address (or a real one used as a test) gets emailed. The 2 spam rows were imported tagged suspect-spam and deliberately not reactivation, so they are visible for review but cannot enter a campaign audience.

The 18 test rows were deleted from the CRM on 2026-09-07 with sign-off. Baseline is now 38 contacts / 32 leads / 43 activities.

Workflows

Workflow State Does
CRM Sync — opportunities to ATG Central CRM Published Triggers on Opportunity Created + Pipeline Stage Changed; POSTs to the bridge with a shared-secret header
Speed to Lead — internal alert + call task Published On Opportunity Created: internal email to all users + a call task assigned and dated. Internal only — no customer contact, so it runs before A2P

Funnel

SWTR — Water Truck Rental (Phoenix), step Rental Request at /water-truck-rental-phoenix. Needs a domain configured before it can go live, and page content is better authored as HTML on the existing site than through the drag-and-drop editor.

The two services

ghl-bridge — inbound (GHL → CRM)

D:\Projects\ghl-bridge · nssm service, auto-start, 127.0.0.1:8103 · ~560 lines.

Public endpoint https://crm.copperstatetruckparts.com/webhook/ghl — reuses the existing CRM hostname with an exact path match above the catch-all rule, so no new DNS record was needed. Authenticated by an X-Bridge-Token shared secret; unauthenticated posts get 401.

Content-derived idempotency. The key is a SHA-256 of the payload, not a random UUID. GHL retries webhooks aggressively, and a key that never repeats dedupes nothing — it only looks like protection. Verified: three identical POSTs returned the same contact id and created exactly one row.

Status semantics are deliberate. Transient failures (5xx, network, 429) return 503 so GHL retries — safe because of idempotency. Permanent rejections (4xx validation) return 200 and dead-letter, because making GHL retry a payload that can never succeed is just noise.

Identity guard. A payload with neither email nor phone has nothing to dedupe on, so it can only mint junk — rejected as permanent. Found in testing, when an empty body produced an "Unknown (GHL)" contact.

🟢 Confirmed — the loop guard, and why it exists

The poller creating a GHL opportunity fires the published CRM Sync workflow, which POSTs straight back to the bridge. Without a guard the bridge would treat it as new and mint a second CRM lead — for a lead that came from the CRM. Every web lead would duplicate, forever.

The guard works because the poller stamps crm_contact_id / crm_lead_id onto every GHL contact it creates, so the mapping travels in the payload; the bridge reads them and patches instead of creating. Verified live: a round trip patched an existing lead and the lead count did not move.

Do not remove those two custom fields from the poller's upsert.

Location allowlist. Any webhook whose locationId is not on the allowlist gets 403 even with a valid token. This exists because Sovran will host unrelated businesses as sibling sub-accounts, and GHL snapshots copy workflow actions verbatim — including this bridge's URL and its secret header. A cloned snapshot would otherwise hand another company a working key into the ATG warehouse. The file is re-read per request, so adding a sub-account needs no restart.

sync_out.py — outbound (CRM → GHL)

Windows Scheduled Task ghl-bridge-sync-out, every 5 minutes.

Deliberately a poller against the CRM database, not a change to seq-lead-api / swtp-lead-api / claude-swtr-api. Those are live lead capture; adding a second network call inside them would put GHL's availability in the path of capturing a lead, and a lost lead is far worse than a late one. Decoupled: if GHL is down the poller retries and capture never notices.

It applies the same test/internal/spam classification as the initial import — a marketing system must never be fed smoke tests. Verified in production: it caught and skipped a 602-555-1212 / "probe" row unprompted.

Identifiers

Thing Value
GHL location 4RXlmwVplpFvBCa6I2Ic
GHL agency company 0puzCjaxC66spVM5yCa1 (Sovran Group)
SWTR pipeline hZJsWSEZTy8CkT9LMcoI
SEQ pipeline h5woV1hIeB44q4ej9USE
SWTP pipeline ncANikt8Ta5OCVzQYUqZ
CRM API http://192.168.1.205:8090 (LAN) · https://crm.copperstatetruckparts.com (public, live)
Secrets .secrets\ghl\tokens.env · .secrets\crm\tokens.env

🔴 Fragility — pipeline ids are configuration

The bridge routes on GHL pipeline id. Delete and recreate a pipeline and its id changes, and leads silently route to the default brand. Update config.PIPELINES and restart if a pipeline is ever rebuilt.

Gotchas — learned the hard way

These cost real time. They are recorded so they cost it once.

  1. GHL's API is behind Cloudflare, which 403s default script user-agents (error 1010, browser_signature_banned). It returns a Cloudflare page, not a GHL error, so it reads exactly like a bad token. Always send an explicit User-Agent.
  2. Pipeline creation is UI-onlyPOST /opportunities/pipelines returns 401 for any scope. Pipeline updates (PUT) work fine; an early 401 on create was wrongly generalised to all pipeline writes.
  3. A minimized or background Chrome window breaks the GHL builder. Dropdowns silently refuse to open and fields swallow typing. This was initially misdiagnosed as "GHL's visual builders cannot be automated"; Brandon identified the real cause. Restore the window first.
  4. Merge-field validity shows as chip colour — blue valid, red invalid. {{opportunity.pipeline_name}} is valid; {{opportunity.pipeline_stage}} is not.
  5. Task due-date units are Days / Weeks / Months / Years only — no minutes or hours. A five-minute SLA cannot be a task due date; it lives in the immediate alert with the task as a same-day backstop.
  6. A2P 10DLC registration is per-EIN, which is the mechanical reason three sub-accounts are needed rather than one location with tags.
  7. The CRM's idempotency middleware 422s when one key arrives with a different body, so keys must hash the full payload, never a subset.

Sub-account split — decided, blocked

Three sub-accounts, one per entity. The decisive reason is mechanical, not tidiness: A2P registration is one brand per sub-account tied to one EIN, so keeping all three in one location means every SMS forever goes out under Superior Equipment's brand — SWTR and SWTP could never text under their own identity. Reinforced by per-domain sending reputation, three separate Google Business Profiles, and clean per-entity reporting.

🔴 Blocked — we cannot create sub-accounts

Confirmed two ways: the API returns 403 on agency endpoints (the token is location-scoped), and the UI account switcher lists exactly one account with no agency view. Sovran must provision them, and supply either an agency API key or a Private Integration token per sub-account.

provision_location.py is written and dry-run verified: it stamps a fresh sub-account with the 21 custom fields, brand-filtered contacts and their opportunities. Deliberately a script rather than a GHL snapshot — snapshots carry webhook URLs and secret headers; the script carries no credentials.

Known defects and open items

Item Detail
Cosmetic The Speed-to-Lead email body still contains the invalid {{opportunity.pipeline_stage}}. Renders as literal text in an internal email; no functional impact. 20-second fix when that action is next open.
Task assignment Assigned to a named user because no contact has an owner yet. Switch to "Contact's Assigned User" once staff exist.
Blocked A2P 10DLC (needs EIN docs) blocks all SMS. SEQ/SWTP sending domain — both still send from a domain with no MX, so customer replies bounce.
Blocked SWTR has no phone number. VoIP.ms carries no Phoenix-metro inventory; request open as ticket #W7XC7R, GHL provisioning as fallback.
Not started Server-side content-derived dedupe in the CRM, so the guarantee does not depend on every caller behaving.

No customer-facing message has been sent by any part of this system.