# Track Hub — guia de integração (portável)

> Guia único e self-contained para integrar QUALQUER sistema ao Track Hub. Funciona em
> qualquer ferramenta de IA (Cursor, Claude Code, Copilot…): salve como `AGENTS.md`, em
> `.cursor/rules/track-hub.mdc`, ou cole no contexto do agente. Gerado de skills/integrar-track-hub —
> não edite à mão: edite a skill e rode scripts/build-skill.sh.


# Integrar um sistema ao Track Hub

## O que é o Track Hub (explique assim)

É um **serviço central** (fora do seu sistema) que recebe uma conversão — um **lead** (alguém virou
contato) ou um **evento** (um clique, uma etapa avançou) — guarda num banco e a **redistribui** para
as ferramentas: o **CRM** (a agenda de vendas), a **Meta** (para os anúncios otimizarem) e o **GA4**
(analytics). Seu sistema **não** fala com CRM/Meta/GA4 direto: ele manda **uma** chamada ao hub, e o
hub entrega para todos, com **retry** (reenvia se falhar) e **deduplicação** (não conta 2x).

> Por que central: um lugar só para o tracking = uma integração só para manter, entrega garantida, e a
> troca de destino (ex.: mudar de CRM) é config no hub — seu sistema não muda.

## Os 2 tipos de entrada (é só isto)

Toda entrada é de **um de dois tipos**:

| Tipo | Porta | O que faz | Quando |
|---|---|---|---|
| **LEAD** | `POST /t/<slug>/api/webhooks/lead` | cria o lead → **CRM + Meta + GA4** | tem identidade (e-mail/telefone): formulário, cadastro |
| **EVENTO** | `POST /t/<slug>/api/webhooks/event` | só dispara **Meta / GA4** (sem CRM) | conversão sem criar contato: clique, etapa de funil |

O header **`X-Source`** diz **qual fonte** está mandando (o hub sabe como ler cada payload). Ex.:
`X-Source: landing_page`, `zapier_meta`, `lp_click`, `lp_event`, `crm_movement`. Fonte nova = uma
linha no hub — a porta já existe. Detalhe: `references/contrato.md`.

> **Legado:** existem 5 endpoints antigos por-fonte (`/lp`, `/zapier`, `/lp-click`, `/lp-event`,
> `/crm`). São **aliases** mantidos por compat. **Integração nova usa as 2 portas acima.**

## Duas formas de autenticar (escolha uma)

O hub aceita a conversão por **um de dois modos**. Ambos valem nas 2 portas (`/lead` e `/event`).

**1. Origin — browser direto, SEM segredo (o mais simples).**
A página posta **direto no hub** do próprio browser (`fetch`, sem `X-Signature`). O hub autoriza pelo
header **`Origin`** — que o navegador coloca sozinho e o JS não consegue forjar — desde que o domínio
da página esteja na **allowlist do tenant** (`lp_allowed_origins`, o dono do hub adiciona). **Não precisa
de Worker nem segredo.** Ideal para **LP HTML puro / estática**. O hub responde os headers de CORS
(inclui o preflight `OPTIONS`) só para origens permitidas.
⚠️ É por domínio, não é à prova de `curl` (dá pra forjar o `Origin` fora do browser) → best-effort +
dedup por `event_id`. Para leads de **alto volume/valor**, prefira o modo 2.

**2. HMAC — assinado, com segredo (via Worker/backend).**
A chamada é assinada com um segredo compartilhado (`LP_WEBHOOK_SECRET`, HMAC-SHA256 do corpo) no header
`X-Signature`. **O segredo NUNCA pode aparecer no navegador** → quem assina é **um pedacinho de
servidor** (Cloudflare Worker ou o backend do app). Confiança total. **Obrigatório** para
server-to-server (backend, automação) e recomendado para volume alto. Fluxo: *sistema coleta os dados →
manda pro Worker → o Worker assina e injeta IP/UA → hub.*

```
Modo 2: Sistema (browser) ──► Worker (assina HMAC + injeta IP/UA) ──► HUB ─┬─► CRM (GHL ou DM Hub)
Modo 1: Sistema (browser) ─────────Origin (sem segredo)───────────► HUB ─┼─► Meta CAPI
                                                                          └─► GA4
```

Passo a passo, template do Worker e o modo Origin: `references/auth-worker.md` e
`assets/worker-template.js`.

## O que só a pessoa (dona do hub/contas) pode te dar

| Item | O que é |
|---|---|
| **URL do hub + tenant** | ex.: `https://track.basehit.com.br/t/basehit` (o `/t/<slug>` identifica a empresa) |
| **Domínio na allowlist** *(modo 1 — Origin)* | o dono do hub adiciona o **domínio da sua página** (ex.: `lp.qh4.com.br`) em `lp_allowed_origins` do tenant. Casa subdomínio. Sem segredo. |
| **`LP_WEBHOOK_SECRET`** *(modo 2 — HMAC)* | o segredo da assinatura (vai **só** no Worker/backend, nunca no site). **Item sensível — trate como senha.** O dono do hub pega/gera no **admin → card Credenciais → "revelar"** (kinds `webhook_*`, compartilhados) e te repassa. |
| **Pixel ID / GA4 ID** | só se o sistema também dispara Pixel/GA4 no browser (ver a skill da landing) — tem que ser o **mesmo** dataset que o hub usa, senão não deduplica |
| **Custom fields do CRM** | os campos (UTMs etc.) precisam **já existir** no CRM alvo, senão são descartados. O dono do hub mapeia isso no admin. |

## Fluxo de integração (o que você, Claude, faz)

1. **Descubra o tipo** — o sistema cria contato (LEAD) ou é só um evento (EVENTO)?
2. **Monte o payload** conforme `references/contrato.md` (identidade + tokens + UTMs para LEAD; evento
   + tokens para EVENTO). Gere um `event_id` único (dedup com o Pixel, se houver browser).
3. **Envie** — escolha o modo de auth (ver acima): **Origin** (LP HTML puro posta direto, sem segredo —
   só o domínio precisa estar na allowlist) **ou** **HMAC** (o Worker/backend assina e injeta IP/UA).
   `POST` na porta certa com `X-Source` (ou `?source=`). Template do Worker: `assets/worker-template.js`;
   curl assinado: `assets/assinar.sh`.
4. **Roteamento do CRM** é do hub — você não escolhe o CRM no payload; o dono do hub define a rota da
   fonte (GHL ou DM Hub) no admin. Contexto: `references/destinos-crm.md`.
5. **Valide** — `references/validar.md` (inbound_log/ledger do hub, Meta Test Events, GA4 DebugView).
   **Nunca diga "está rastreando" sem checar.**

## Quem chama esta skill (base compartilhada)

Esta skill é a **fonte da verdade do lado do hub**. Ela mora no repositório do Track Hub, junto do
código que implementa o contrato, e é distribuída pelo marketplace **`landing-skills`** por source
externo — não existe cópia dela em outro repositório.

As três skills de landing page dependem desta e **delegam para cá** todo detalhe de hub:

| Skill | Repositório | O que ela faz | O que ela pergunta aqui |
|---|---|---|---|
| `nova-landing-page` | `Basehit/landing-skills` | conduz a pessoa do zero ao fim | qual modo de auth usar (Origin × HMAC) |
| `publicar-landing-page` | `Basehit/landing-skills` | monta a página e o script do navegador | contrato do payload, endpoint, Worker |
| `auditar-trackeamento` | `Basehit/landing-skills` | audita o tracking de uma landing | critério de OK/erro do Bloco 4 |

**Regra de precedência:** se qualquer uma delas divergir desta skill sobre portas, autenticação,
campos ou roteamento, **esta vence** — e a divergência deve ser corrigida lá. Ao mudar o contrato
aqui, verifique o impacto em `references/tracking-hub.md` (publicar-landing-page) e
`references/checklist-auditoria.md` (auditar-trackeamento).

## Referências desta skill

- `references/contrato.md` — payloads das 2 portas, campos, `X-Source`, auth, resposta, dedup.
- `references/auth-worker.md` — como assinar (HMAC), o padrão do Worker, o segredo.
- `references/destinos-crm.md` — como o hub roteia p/ CRM (GHL/DM Hub), Meta e GA4; extensível.
- `references/validar.md` — como confirmar que chegou e disparou.
- `references/exemplos.md` — snippets prontos (coleta de tokens, curl assinado, Worker).
- `references/glossario.md` — Pixel, CAPI, GTM, HMAC, dedup, event_id, UTMs… (traduza na 1ª vez).

> **Fonte da verdade** do contrato é este repositório (`Basehit/trackhub`), em `docs/contratos.md`,
> `docs/contrato-lp.md`, `docs/contrato-lp-event.md`, `docs/crm-providers.md`. Esta skill é a camada
> didática; em dúvida de detalhe, os docs decidem.
>
> **Manutenção:** mudou o contrato, mudou esta skill — no mesmo commit — e sobe a `version` no
> `.claude-plugin/plugin.json`. Quem instalou pelo marketplace `landing-skills` recebe a atualização
> com `/plugin marketplace update landing-skills`. Detalhes em `skills/README.md`.


---

# Contrato do payload — as 2 portas do hub

Leia para montar o `POST` que o sistema (via Worker/backend) manda ao hub. Fonte da verdade:
`docs/contratos.md`, `docs/contrato-lp.md`, `docs/contrato-lp-event.md` do repo do hub.

## HTTP (comum às 2 portas)

- **Método:** `POST` · **Content-Type:** `application/json`
- **URL:** `https://<hub>/t/<slug>/api/webhooks/lead` (LEAD) ou `.../event` (EVENTO)
- **Fonte:** header **`X-Source: <fonte>`** (ou `?source=<fonte>` na query — evita header custom no
  preflight CORS do browser)
- **Auth (um dos três basta):**
  1. **Origin (browser direto, sem segredo):** o header `Origin` do request na allowlist do tenant
     (`lp_allowed_origins`). O hub responde CORS (+ preflight `OPTIONS`) só p/ origens permitidas.
     Vale nas 2 portas. Ideal p/ LP HTML puro.
  2. **HMAC (assinado):** header **`X-Signature: sha256=<hmac>`** = HMAC-SHA256 do **corpo bruto** com o
     secret do tenant (Worker/backend). (Também aceita token direto em `X-Webhook-Token`/`X-Api-Key`/`X-Zapier-Token`.)
  3. **Token na query** (`?token=<secret>`): p/ fontes que **só deixam configurar a URL**, sem headers
     (ex.: webhook do **DMHub** e vários CRMs). Mande tudo na URL:
     `.../webhooks/event?source=<fonte>&token=<secret>` — o `?token=` é dobrado no `X-Webhook-Token`
     (mesma validação). ⚠️ O token vai na URL (pode cair em logs de infra) → use **só server-to-server**
     e **rotacione** se vazar. ⚠️ O secret precisa ser **URL-safe**: os secrets do hub são **hex**
     (0-9a-f), ok; NÃO usar base64 cru (`+`/`/`/`=`) na query sem percent-encode.
  Nenhum dos três → **401**.
- **Resposta:** `200 {"ok": true, "lead_id": "...", "duplicate": false}`. **Retentar** se ≠ 2xx.

## Porta LEAD (`/lead`) — cria lead → CRM + Meta + GA4

`X-Source`: `landing_page` (formulário de LP/app) · `zapier_meta` (Meta Lead Ads via Zapier).

### Campos (fonte `landing_page`)
| Campo | Obrigatório | Observação |
|---|---|---|
| `email` / `phone` | um dos dois | o hub normaliza e hasheia (SHA-256) p/ a Meta |
| `full_name` (ou `name`) | recomendado | nome completo |
| `event_id` | **forte** | id único da conversão; **o mesmo** usado no Pixel `Lead` do browser → dedup CAPI↔Pixel. Faltando, o hub gera (perde a dedup) |
| `client_id` | p/ GA4 | do cookie `_ga` (de `GA1.1.X.Y` use `X.Y`). Sem ele, o GA4 nasce `skipped` |
| `session_id` | p/ GA4 | do cookie `_ga_<ID>` / `gtag('get',id,'session_id')`. Costura na sessão (opcional) |
| `fbp` / `fbc` | se houver | cookies `_fbp` / `_fbc` (ou `fbc` derivado do `fbclid` da URL) |
| `gclid` | Google Ads | da URL |
| `utm_source/medium/campaign/term/content` | recomendado | da URL. Viram custom fields no CRM |
| `consent_ads` | LGPD | `true/false`. **Sem `true`, o hub NÃO manda PII (email/telefone) à Meta** |
| `consent_at` | LGPD | timestamp ISO-8601 |
| `pipeline` | opcional | chave de rota de pipeline. A rota é **escopada por CRM (provider)**: a fonte escolhe o CRM (setting `crm_provider_routes`) e a **mesma chave** resolve p/ o pipeline **daquele CRM**. As chaves válidas são **por-tenant** — peça a lista ao dono do hub. Sem chave → pipeline default do tenant |
| `pixel` | opcional | **multi-pixel:** nome do pixel Meta (cadastrado no hub) → a CAPI vai pra esse pixel. Deve casar com o pixel que o browser disparou (dedup). **Sem `pixel`, o hub usa a `pipeline` como nome do pixel** (fallback) — se o pixel tem o mesmo nome do funil, basta mandar `pipeline`. Sem `pixel` nem pipeline nomeado → pixel default. Nomes válidos são por-tenant |

> **Flexibilidade das UTMs:** você controla os **valores** das 5 UTMs padrão pelo payload. Chaves de
> UTM **fora** dessas 5 (ex.: `utm_id`, `utm_ad`) ficam só no `raw_payload` (auditoria) — **não** viram
> coluna do lead nem vão ao CRM sem uma mudança no hub. Peça ao dono do hub se precisar de uma nova.

O **Worker/backend** injeta, antes de assinar: `client_ip`, `user_agent` (match quality do Meta) e
`event_source_url`. Não os coloque no browser — quem assina injeta.

## Porta EVENTO (`/event`) — só Meta / GA4 (sem CRM)

`X-Source`: `lp_click` (clique anônimo, ex.: WhatsApp) · `lp_event` (evento genérico de conversão) ·
`crm_movement` (mudança de estágio no CRM — reidrata um lead existente).

- **`lp_click`** (anônimo, sem PII): `{ event_id, cta, method, utm_*, event_source_url }` → dispara
  **Meta `Lead` (CAPI web)**. **GA4 NÃO sai do hub** aqui (o GA4 não dedupa server↔browser → o
  `generate_lead` vai só pelo browser via GTM).
- **`lp_event`** (genérico): `{ event: "<NomeDoEvento>", event_id, event_source_url, user_data:{...},
  ga:{ client_id, event }, custom_data:{...}, test_event_code? }` → **Meta** (sempre) + **GA4** (só se
  `ga.client_id` E `ga.event`). Detalhe: `docs/contrato-lp-event.md`.
- **`crm_movement`**: `{ crm_lead_id, stage }` (nomes podem vir aninhados em `customData`) → o hub
  **reidrata** o lead pelo `crm_lead_id` e mapeia o estágio → evento (Meta/GA4). Não cria contato.

## Deduplicação (não conte 2x)

- **Meta:** o `event_id` é a chave. Gere **uma vez** e use no Pixel do browser **e** no payload ao hub
  — a Meta marca *Deduplicated*. Pixel e dataset do hub têm que ser o **mesmo**.
- **Idempotência no hub:** repetir o mesmo `event_id`/`(fonte,request_id)` **não** duplica (o hub
  dedupa por `UNIQUE`). Pode retentar sem medo.
- **GA4:** não dedupa server↔browser → para clique anônimo, GA4 é **só** browser.


---

# Autenticação — Origin (sem segredo) ou HMAC (Worker)

O hub aceita a conversão por **um de dois modos**. Escolha conforme o sistema.

## Modo 1 — Origin (browser direto, SEM segredo)

Para **LP HTML puro / estática**: a página posta **direto no hub** do próprio browser, sem assinar. O
hub autoriza pelo header **`Origin`** (o navegador coloca sozinho; o JS não forja), se o **domínio** da
página estiver na **allowlist do tenant** (`lp_allowed_origins` — o dono do hub adiciona; casa
subdomínio, ex.: `lp.qh4.com.br` cobre por `qh4.com.br`). O hub responde os headers de CORS (inclui o
preflight `OPTIONS`) só para origens permitidas. **Não precisa de Worker nem segredo.**

```js
// LP HTML puro — sem X-Signature; o browser põe o Origin sozinho
await fetch("https://<hub>/t/<slug>/api/webhooks/event?source=lp_event", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ event: "ClickTrial", event_id: crypto.randomUUID(),
    event_source_url: location.href, consent_ads: true, user_data: { /* email/phone/fbp/fbc */ } }),
});
```

⚠️ É por domínio, **não é à prova de `curl`** (dá pra forjar o `Origin` fora do browser) → best-effort +
dedup por `event_id`. Para **lead de alto volume/valor** (escreve no CRM), prefira o Modo 2.
Único ponto que NÃO tem modo Origin: `X-Source: lp_click` (segue só HMAC).

## Modo 2 — HMAC (assinado, via Worker/backend)

### Por que assinar

O hub aceita a conversão só se o corpo vier **assinado** com o segredo compartilhado
(`LP_WEBHOOK_SECRET`) via `X-Signature: sha256=<hmac>` (HMAC-SHA256 do **corpo bruto**). Sem isso →
**401**. Assim ninguém injeta leads falsos. **O segredo nunca pode aparecer no navegador** — se for
para o HTML/JS, está vazado.

Regra: **quem tem o segredo assina.** Dois cenários:

- **Sistema com navegador (LP, front)** → o browser coleta os dados (sem o segredo) e manda para um
  **Worker/backend**, que assina e repassa ao hub. (O browser não assina.)
- **Sistema server-to-server (backend, automação, cron)** → o próprio servidor assina e posta direto
  no hub. Não precisa de Worker.

## Assinar (qualquer linguagem)

`assinatura = HEX( HMAC_SHA256( LP_WEBHOOK_SECRET, corpo_bruto_exato ) )` e envie
`X-Signature: sha256=<assinatura>`. **Assine exatamente os bytes que você envia** (se injetar
`client_ip`/`user_agent`, faça isso ANTES de assinar e mande o mesmo corpo). Exemplos prontos em
`assets/assinar.sh` (curl+openssl) e `assets/worker-template.js` (Cloudflare Worker).

## O Worker (padrão para sistemas com navegador)

Um Cloudflare Worker (ou rota de backend) que: recebe o POST do browser, **injeta** `client_ip`/
`user_agent` reais do visitante (melhora o *match quality* da Meta), **assina** o corpo e repassa ao
hub na porta certa com `X-Source`. O segredo vive nos *secrets* do Worker, nunca no código:

```bash
npx wrangler secret put LP_WEBHOOK_SECRET   # o segredo (nunca commitar)
# HUB_URL (ex.: https://track.basehit.com.br/t/basehit) pode ir em [vars] no wrangler.toml
```

> ⚠️ **Cloudflare Workers Builds apaga variáveis a cada deploy** se elas não estiverem no
> `wrangler.toml`. Coloque `keep_vars = true` no `wrangler.toml` e ponha a config não-secreta em
> `[vars]`; secrets via `wrangler secret put` (persistem). Template completo: `assets/worker-template.js`.

Uma rota do Worker por tipo, cada uma com seu `X-Source`:

| Rota do Worker | Porta do hub | `X-Source` |
|---|---|---|
| `/api/track/lead` | `/t/<slug>/api/webhooks/lead` | `landing_page` |
| `/api/track/event` | `/t/<slug>/api/webhooks/event` | `lp_event` |
| `/api/track/click` | `/t/<slug>/api/webhooks/event` | `lp_click` |

## Segurança

- O `LP_WEBHOOK_SECRET` é a **única** coisa sensível do lado do integrador — trate como senha, só no
  Worker/backend. IDs (pixel, GA4, pipeline, tenant) **não** são segredos.
- Se um lead de teste retorna **401**, quase sempre é: segredo errado/ausente, ou o corpo assinado ≠
  corpo enviado (assinou antes de injetar IP/UA, ou reencodou o JSON).
- Nunca poste do browser direto no hub — sem o Worker não há assinatura → 401 (e o segredo vazaria).


---

# Destinos — para onde o hub manda (CRM, Meta, GA4)

Importante para quem integra: **você não escolhe o destino no payload.** Você manda o lead/evento ao
hub; **o dono do hub configura** para onde vai (no admin). Isso é a força do modelo — trocar de CRM,
adicionar um destino, mudar o pipeline = config no hub, seu sistema não muda.

## CRM — roteado por fonte (multi-provider)

Na ENTRADA (LEAD), o hub cria o contato/negócio em **um CRM escolhido pela fonte** do lead
(setting `crm_provider_routes`, ex.: `{ "landing_page": "dmh", "zapier_meta": "ghl" }`). Providers hoje:

- **Go High Level (GHL)** — fluxo contato → oportunidade; UTMs como custom fields (chave = nome).
- **DM Hub** — fluxo contato → negócio; UTMs como custom fields do **negócio** (por `fieldId`).

O `id` do contato criado vira o **`crm_lead_id`** (chave de join da movimentação). A **movimentação**
(`/event` + `X-Source: crm_movement`) NÃO escreve no CRM — o CRM é a **fonte** dela.

> **"Ou onde for":** o CRM é um **provider plugável**. Adicionar um destino novo (outro CRM, um data
> warehouse, uma fila) é uma implementação nova no hub + uma rota — o **contrato de entrada não muda**.
> Detalhe de implementação: `docs/crm-providers.md` do repo do hub.

### Custom fields (UTMs etc.) precisam existir no CRM
Os campos que o hub manda (`utm_source/medium/campaign/term/content`, `track_hub_id`, `meta_lead_id`)
têm que **já existir** na conta do CRM alvo, senão o CRM os descarta em silêncio. No DM Hub eles são
mapeados por **`fieldId`** (setting `dmh_custom_fields`); no GHL, por nome. Quem configura é o dono do
hub, pelos cards do admin (`Roteamento de CRM`, `DM Hub — mapa de campos`).

## Meta (Conversions API)

- **LEAD/EVENTO de LP** → Meta **CAPI web** com `event_id` + `fbp`/`fbc` (dedup com o Pixel do browser).
- **Meta Lead Ads** (`zapier_meta`) → **CAPI for CRM** com o `lead_id` do Meta (sem `fbc`/`client_id`).
- PII (email/telefone) só sai com `consent_ads: true`, hasheada (SHA-256). Janela de 7 dias do CAPI.

## GA4 (Measurement Protocol)

- Sai do hub só quando há `client_id` (e, para `lp_event`, `ga.event`). Sem `client_id` → `skipped`.
- **GA4 não dedupa server↔browser** → para clique anônimo, o `generate_lead` vai **só** pelo browser
  (GTM); o hub não manda GA4 nesse caso, para não contar 2x. A Meta, por deduplicar via `event_id`,
  aceita os dois caminhos.

## Confiabilidade (o que o hub garante)

- **Persist-before-ACK:** grava a entrada no banco antes de responder → nada se perde.
- **Outbox + retry:** dispara na hora; um cron reenvia falhas com backoff. Falha de Meta/GA4/CRM não
  perde o lead.
- **Idempotência (UNIQUE):** webhook repetido não duplica. Pode retentar.


---

# Validar — nunca diga "está rastreando" sem checar

Depois de ligar a integração, confirme de ponta a ponta. Comece por um lead/evento de **teste**
(e-mail tipo `teste-...@example.com`) e **limpe** os registros de teste depois (contato no CRM e, se
possível, a linha no ledger do hub).

## 1. A resposta do POST

- **`200 {"ok":true, "lead_id":"...", "duplicate":false}`** → entrou e processou.
- **`401`** → assinatura inválida (segredo errado, ou corpo assinado ≠ enviado). Ver `auth-worker.md`.
- **`400 "source ausente/desconhecido"`** → faltou/errou o header `X-Source`.
- **`503`** → tenant pausado no hub (fale com o dono do hub).

## 2. No hub (com o dono do hub, ou via scripts do repo)

- **`inbound_log`** deve ter a linha da sua fonte com `signature_valid = true` e `processed = true`.
- **`dispatches`** do lead: `crm`, `meta`, `ga4` com status `sent` (ou `skipped` quando esperado —
  ex.: GA4 sem `client_id`).
- Para LEAD: confirme o **contato/negócio criado no CRM** e o `crm_lead_id` gravado.

## 3. Meta — Events Manager → Test Events

- Veja o evento chegando. Quando o Pixel (browser) **e** a CAPI (hub) mandam com o **mesmo `event_id`**
  e dataset, o painel indica **Deduplicated** (não conta 2x). Se contar 2x → `event_id` diferente.
- Para testar sem sujar produção, use `test_event_code` (na fonte `lp_event`).

## 4. GA4 — DebugView

- Veja o evento (ex.: `generate_lead`) com os parâmetros/UTMs. Lembre: no clique anônimo o GA4 vem
  **só** do browser.

## Tropeços comuns

- **401** → segredo errado/ausente, ou assinou o corpo errado. | **400** → `X-Source` faltando.
- **Conta 2x na Meta** → `event_id` diferente entre Pixel e hub. Gere **uma vez**, use nos dois.
- **Conta 2x no GA4** → alguém ligou o evento no browser **e** no hub. Clique anônimo = só browser.
- **CRM sem UTM** → o custom field não existe na conta do CRM (descartado em silêncio). Crie antes.
- **"Não chega nada"** → o sistema postou **direto** no hub (sem passar pelo Worker → sem assinatura),
  ou `HUB_URL`/`X-Source` errados.


---

# Exemplos prontos

## A. Coletar os tokens no navegador (para montar o payload)

```js
const q = new URLSearchParams(location.search);
const cookie = (n) => (document.cookie.match("(^|; )" + n + "=([^;]*)") || [])[2];

const utms = {};
["utm_source","utm_medium","utm_campaign","utm_content","utm_term"]
  .forEach((k) => { if (q.get(k)) utms[k] = q.get(k); });

const fbclid = q.get("fbclid");
const ga = cookie("_ga"); // "GA1.1.1234567890.1700000000"
const payload = {
  // ...identidade do formulário (email/phone/name) para LEAD...
  event_id: "lead-" + (crypto.randomUUID?.() ?? Date.now()),
  client_id: ga ? ga.split(".").slice(2).join(".") : undefined, // "1234567890.1700000000"
  fbp: cookie("_fbp"),
  fbc: cookie("_fbc") || (fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined),
  gclid: q.get("gclid") || undefined,
  consent_ads: true,
  event_source_url: location.href,
  ...utms,
};
// mande `payload` ao SEU Worker (não ao hub direto) — o Worker assina. keepalive p/ sobreviver à navegação.
fetch("/api/track/lead", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), keepalive: true });
```

O **mesmo `event_id`** deve ir no Pixel do browser: `fbq('track','Lead',{},{eventID: payload.event_id})`.

## B. Worker que assina e repassa

Template completo em `assets/worker-template.js`. Ele injeta `client_ip`/`user_agent`, assina o corpo
com HMAC e faz `POST` na porta do hub com `X-Source`. Deploy: `wrangler deploy` + `wrangler secret put
LP_WEBHOOK_SECRET`.

## C. Server-to-server (backend/automação, sem browser)

Quando não há navegador, o próprio backend assina e posta. Exemplo em `assets/assinar.sh`
(curl + openssl). Em Node:

```js
import { createHmac } from "node:crypto";
const body = JSON.stringify({ email: "fulano@x.com", full_name: "Fulano", event_id: "lead-abc",
  consent_ads: true, utm_source: "newsletter" });
const sig = createHmac("sha256", process.env.LP_WEBHOOK_SECRET).update(body).digest("hex");
await fetch("https://track.basehit.com.br/t/basehit/api/webhooks/lead", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-Signature": `sha256=${sig}`, "X-Source": "landing_page" },
  body,
});
```

> Assine **exatamente** o `body` que você envia. Não reserialize entre assinar e enviar.


---

## Anexo — Worker (template pronto)

```js
// Template de Cloudflare Worker — ponte assinada entre o navegador e o Track Hub.
// O browser NÃO fala com o hub direto (o segredo vazaria). Ele posta aqui; este
// Worker injeta IP/User-Agent reais, ASSINA o corpo (HMAC-SHA256) e repassa ao hub
// na porta certa com o header X-Source.
//
// Config (secrets/vars do Worker — nunca no código):
//   wrangler secret put LP_WEBHOOK_SECRET     # segredo da assinatura
//   HUB_URL em [vars] do wrangler.toml, ex.: https://track.basehit.com.br/t/basehit
//   (use keep_vars = true no wrangler.toml p/ o deploy não apagar as vars)

const ROUTES = {
  // rota no Worker → { path no hub, X-Source }
  "/api/track/lead": { path: "/api/webhooks/lead", source: "landing_page" },
  "/api/track/event": { path: "/api/webhooks/event", source: "lp_event" },
  "/api/track/click": { path: "/api/webhooks/event", source: "lp_click" },
};

const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

const json = (obj, status = 200) =>
  new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json", ...CORS } });

async function hmacHex(secret, message) {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
  return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

export default {
  async fetch(request, env) {
    const { pathname } = new URL(request.url);
    const route = ROUTES[pathname];
    if (!route) return json({ error: "not found" }, 404);
    if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
    if (request.method !== "POST") return json({ error: "method not allowed" }, 405);
    if (!env.LP_WEBHOOK_SECRET || !env.HUB_URL) return json({ error: "tracking não configurado" }, 500);

    let body;
    try {
      body = await request.json();
    } catch {
      return json({ error: "JSON inválido" }, 400);
    }

    // injeta contexto real do visitante ANTES de assinar (match quality do Meta)
    const enriched = {
      ...body,
      client_ip: request.headers.get("CF-Connecting-IP") || undefined,
      user_agent: request.headers.get("User-Agent") || undefined,
      event_source_url: body?.event_source_url || request.headers.get("Referer") || undefined,
    };
    const payload = JSON.stringify(enriched);
    const signature = await hmacHex(env.LP_WEBHOOK_SECRET, payload);

    let hubResp;
    try {
      hubResp = await fetch(env.HUB_URL.replace(/\/+$/, "") + route.path, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Signature": `sha256=${signature}`,
          "X-Source": route.source,
        },
        body: payload,
      });
    } catch (e) {
      return json({ error: `falha ao encaminhar ao hub: ${e.message || e}` }, 502);
    }

    const text = await hubResp.text();
    return new Response(text, { status: hubResp.status, headers: { "Content-Type": "application/json", ...CORS } });
  },
};
```

## Anexo — assinar.sh (POST assinado, server-to-server)

```bash
#!/usr/bin/env bash
# Envia um LEAD assinado ao Track Hub (server-to-server, sem browser/Worker).
# Uso:
#   export LP_WEBHOOK_SECRET='o-segredo'
#   ./assinar.sh 'https://track.basehit.com.br/t/basehit' landing_page
#
# Assina o corpo com HMAC-SHA256 (openssl) e manda em X-Signature + X-Source.
# Assine EXATAMENTE os bytes enviados — por isso o mesmo $BODY vai nos dois lugares.
set -euo pipefail

HUB_BASE="${1:?informe a base do hub, ex.: https://track.basehit.com.br/t/basehit}"
SOURCE="${2:-landing_page}"
: "${LP_WEBHOOK_SECRET:?defina LP_WEBHOOK_SECRET no ambiente}"

# corpo do lead de teste (troque os valores)
BODY='{"email":"teste@example.com","full_name":"Teste Hub","event_id":"teste-'"$(date +%s)"'","consent_ads":true,"utm_source":"teste","utm_campaign":"assinar-sh"}'

SIG="$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$LP_WEBHOOK_SECRET" -hex | sed 's/^.*= //')"

curl -sS -X POST "${HUB_BASE%/}/api/webhooks/lead" \
  -H "Content-Type: application/json" \
  -H "X-Signature: sha256=${SIG}" \
  -H "X-Source: ${SOURCE}" \
  --data "$BODY"
echo
```
