# External-Funnel Lead Bridge (power-user, self-hosted)

When a Blueprint funnel is `target: "external"`, GHL Command does **not** build or
host the site — you do, on your own Cloudflare/Vercel account, and wire its form
back to your GHL sub-account with this lead bridge. **GHL Command never holds your
keys, deploys for you, or touches your accounts.** This is a technically-capable,
self-serve path — not a novice one.

## What you need (capability check — be honest before you start)
- Your own **Cloudflare** (or Vercel) account + the CLI (`wrangler` / `vercel`).
- A **GHL Private Integration token** (Settings → Private Integrations) scoped to
  `contacts.write` for the target sub-account. You'll store it as a host secret.
- Comfort with a one-time command-line deploy and editing a config file.
- (Optional) a custom **domain** + DNS access to point it at the site.

If any of that isn't you, choose `target: "ghl"` instead — Blueprint builds the
funnel inside GHL with no setup on your part.

## The pieces
1. **Your site** — generated by the Blueprint skill (frontend-design) or your own;
   hosted on Cloudflare Pages / Vercel. Its form POSTs JSON to the bridge.
2. **The lead bridge** — `cloudflare-worker.js` here (Vercel variant is analogous:
   same logic in an `api/lead.js` handler; read config from `process.env`).
3. **Your GHL sub-account** — already built by `apply_build_plan`: the custom
   fields, the trigger tag, and the speed-to-lead workflow. The bridge feeds it.
4. **Booking** — link your site's "book" button straight to the GHL calendar's
   public URL (no bridge needed).

## Wiring values (from `apply_build_plan`)
The executor returns what you plug into the Worker config + form:
- `GHL_LOCATION_ID` — the sub-account id.
- `TRIGGER_TAG` — the tag that starts your workflow.
- the custom **field IDs** your form's `custom` object should send (`{ "<fieldId>": value }` — GHL's upsert keys custom fields by id, not name).
- the calendar **booking URL** for the site's CTA.

## Deploy (Cloudflare)
1. `npm i -g wrangler && wrangler login`
2. `wrangler secret put GHL_PIT`  ← paste your token (secret; never in code/repo)
3. `cp wrangler.toml.example wrangler.toml`, then fill in the four values it
   asks for: `GHL_LOCATION_ID`, `TRIGGER_TAG`, `ALLOWED_ORIGIN` (your funnel
   domain — locks CORS), and optional `RE_ENROLL`. The example file ships beside
   this README with each value explained and the right/wrong forms of
   `ALLOWED_ORIGIN` spelled out.
4. (Optional) Turnstile: add the widget to your form, `wrangler secret put
   TURNSTILE_SECRET`.
5. `wrangler deploy` → point your form at the Worker URL.

## Why a bridge and not a raw GHL inbound-webhook
A GHL **inbound-webhook-triggered workflow runs *contactless*** — unless that
workflow has a Create/Update Contact action with every payload field mapped, GHL
runs the whole thing into the void: tags fire on nobody, emails send to no one,
the execution log still shows green, and **no contact is ever created.** The lead
silently evaporates. This bridge avoids that entire failure class by creating the
contact itself via `/contacts/upsert` (the contacts API, not a trigger), then
adding the tag — so your workflow always fires on a **real contact**. If you ever
wire a raw inbound webhook instead, it MUST start with a mapped Create/Update
Contact action or it's a paper shredder.

## Safety built in
- **Lead is actually created** — the bridge writes the contact through the
  contacts API and only reports success on a 2xx from GHL (not a thank-you page).
  See "Why a bridge…" above for the contactless-webhook trap this sidesteps.
- **Secret stays server-side** — the token lives only as a Cloudflare secret, never
  in the browser.
- **Spam gate** — honeypot field (`_hp`) + Cloudflare Turnstile. **Strongly enable
  Turnstile in production:** this is a public, unauthenticated endpoint, and the
  honeypot only filters naive browser bots — without Turnstile, any scripted client
  can post directly. Turnstile is the real abuse gate.
- **Dedup** — uses `/contacts/upsert` (by email/phone), so repeat submits update,
  never duplicate.
- **Fail-closed** — if GHL can't be reached, the form gets an error (so the lead is
  retried), not a silent success that drops it. Logs to `wrangler tail`.
- **Origin check** — `ALLOWED_ORIGIN` limits browser cross-origin access and is
  required (no wildcard). NOTE: this is **not** authentication — it does not block
  `curl` or server-side clients (they send no/forged Origin). Use Turnstile for that.

## Re-enrollment caveat
GHL's Contact-Tag trigger fires on a tag **transition**, so re-adding a tag a
contact already has won't re-fire the workflow. For speed-to-lead that's usually
what you want. If you need returning submitters re-enrolled, set `RE_ENROLL=true`
(the Worker removes + re-adds the tag) AND enable re-enrollment on the workflow.
Confirm the behavior on your account before relying on it.

## Verify after deploy (the form is not proof)
A live form, a success toast, and a green workflow run are **not** verification —
a funnel can show every happy signal while the contact never lands. Before you
call it done, prove it on the **real production URL** (not the worker in isolation,
not a local deploy):
1. **Happy path → backend truth.** Submit the live form with a real email, wait
   ~60s, then `search_contacts` for it. No contact = your form is a paper shredder.
2. **Field persistence — check directly.** Open the created contact and confirm
   each custom field actually saved. A wrong/stale field id makes upsert succeed
   while silently dropping that value. This is why the wiring bundle gives you
   **verified GHL field IDs** — send those, never guessed names/keys.
3. **Duplicate-contact retest.** Submit the SAME email twice. Correct result: one
   contact (upsert deduped), fields updated, no error. A second contact or a
   false failure means dedup is broken — catch it before real traffic does.
4. **Booking, if you wired it.** A reachable calendar URL is not proof. Book one
   real test ("burner") appointment through it and confirm it lands in GHL.

## Compliance
If your workflow sends SMS/email, your external form MUST collect explicit consent
(a checkbox + clear copy). Define a **consent custom field** in your Blueprint plan
and send its value in `custom` (keyed by that field's id) like any other field, so
the consent is recorded on the contact. You own CAN-SPAM/TCPA compliance for an
externally-captured lead.
