Lead Engine (wiring)¶
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
leadstable: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.
- 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 explicitUser-Agent. - Pipeline creation is UI-only —
POST /opportunities/pipelinesreturns 401 for any scope. Pipeline updates (PUT) work fine; an early 401 on create was wrongly generalised to all pipeline writes. - 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.
- Merge-field validity shows as chip colour — blue valid, red invalid.
{{opportunity.pipeline_name}}is valid;{{opportunity.pipeline_stage}}is not. - 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.
- A2P 10DLC registration is per-EIN, which is the mechanical reason three sub-accounts are needed rather than one location with tags.
- 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.