# RunaTrack: guia de integração

Uma única API de pagamentos. Você integra uma vez; a gateway usada por trás (Beehive, Velani, Pingu, Pague-X ou FreePay) é escolhida no painel e pode ser trocada sem alterar o seu código.

- [Configuração](#configuração)
- [Criar cobrança](#criar-cobrança)
- [Gateway por funil](#gateway-por-funil)
- [Consultar cobrança](#consultar-cobrança)
- [Webhook (aviso de pagamento)](#webhook-aviso-de-pagamento)
- [Erros](#erros)
- [Diferenças entre gateways](#diferenças-entre-gateways)
- [Boas práticas](#boas-práticas)
- [Capturar IP e token de cartão](#capturar-ip-e-token-de-cartão)
- [Exemplo em Python](#exemplo-em-python)
- [Usar com uma IA](#usar-com-uma-ia)

## Configuração

No projeto (variáveis de ambiente, **somente servidor**):

| Variável | Valor | Obrigatória |
|---|---|---|
| `HUB_URL` | URL do hub, sem barra final. Ex.: `https://localhost:3003` | sim |
| `HUB_API_KEY` | Sua chave `hub_live_...` | sim |
| `HUB_SLUG` | Nome do funil deste projeto (ex.: `funil1`), o mesmo cadastrado em *Gateway por funil* no painel. Define qual gateway cobra este projeto. | não (sem ele usa a gateway ativa) |
| `HUB_WEBHOOK_SECRET` | Segredo de assinatura (`whs_...`), visível no painel | só se receber webhook |

> A chave é secreta. Nunca a exponha no navegador (não use `NEXT_PUBLIC_`).
> Remova do projeto as variáveis das gateways antigas: elas agora ficam só no painel do hub.

**Painel:** acesse `HUB_URL/login` com a sua chave. Lá você cola as API keys das gateways, escolhe a gateway ativa e informa a URL do seu webhook.

Todas as chamadas levam o header:

```
Authorization: Bearer hub_live_...
Content-Type: application/json
```

## Criar cobrança

`POST /api/v1/charges`

### Corpo

| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `amount` | inteiro | sim | Valor em **centavos** (`1990` = R$ 19,90). |
| `method` | string | sim | `pix`, `boleto` ou `card`. |
| `customer.name` | string | sim | Nome do pagador. |
| `customer.email` | string | sim | E-mail do pagador. |
| `customer.document` | string | depende | CPF/CNPJ (só números). **Obrigatório** em Beehive, Pingu, Pague-X e FreePay. |
| `customer.phone` | string | depende | Telefone com DDD. **Obrigatório** na Pingu e na FreePay. |
| `customer.address` | objeto | só boleto na Pague-X | `{ zipCode, street, number, city, state, neighborhood? }`. |
| `reference` | string | recomendado | Id do pedido no seu sistema. Volta na resposta e evita duplicidade em algumas gateways. |
| `description` | string | não | Descrição da cobrança. |
| `ip` | string | depende | IP do **comprador**. **Obrigatório** na Beehive. Se omitido, o hub usa o IP encaminhado na requisição (normalmente o do seu servidor, então prefira enviar). |
| `cardToken` | string | só `card` | Token/hash gerado no front pelo SDK da gateway (Beehive: `card.hash`; Pague-X: `card.token`; FreePay: hash do SDK). O hub nunca recebe o número do cartão. |
| `installments` | inteiro | não | Parcelas do cartão (padrão 1). |
| `funnel` | string | não | Slug do funil (`a-z`, `0-9`, `.`, `_`, `-`; até 60). Se você mapeou este funil a uma gateway no painel (aba *Funis*; no projeto, basta `HUB_SLUG=<slug>` no `.env`), a cobrança usa ela; sem mapeamento, usa a gateway padrão. Também aceito no header `X-Hub-Funnel`. Veja [Gateway por funil](#gateway-por-funil). |
| `shopUrl`, `checkoutUrl` | string | não | URLs da loja/checkout (a Beehive as exige no metadata; padrão: URL do hub). |
| `items` | lista | não | Produtos da cobrança, para o relatório por produto e para a Meta: `[{ id, name, price (centavos, unitário), quantity? (padrão 1), kind?: "main" | "bump" | "upsell" }]`, até 20 itens. Não é enviado à gateway. |
| `tracking` | objeto | não | Origem do comprador para o [rastreamento](#rastreamento-utms): `{ visitorId?, attribution?, ua? }`. Não é enviado à gateway. |

### Exemplo

```bash
curl -X POST "$HUB_URL/api/v1/charges" \
  -H "Authorization: Bearer $HUB_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "amount": 1990,
    "method": "pix",
    "funnel": "'"$HUB_SLUG"'",
    "reference": "pedido-123",
    "description": "Plano mensal",
    "customer": { "name": "Maria Silva", "email": "maria@ex.com", "document": "12345678900", "phone": "11999999999" },
    "ip": "189.10.20.30"
  }'
```

### Resposta `201`

```json
{
  "id": "velani_8f2a1c",
  "gateway": "velani",
  "status": "pending",
  "amount": 1990,
  "method": "pix",
  "reference": "pedido-123",
  "pix": { "copyPaste": "00020126...", "qrCode": "data:image/png;base64,..." },
  "expiresAt": "2026-11-05T15:30:00.000Z"
}
```

| Campo | Descrição |
|---|---|
| `id` | Id da cobrança no hub (`<gateway>_<idExterno>`). **Guarde-o**: é com ele que você consulta. |
| `gateway` | Gateway que processou. |
| `status` | `pending`, `paid`, `failed`, `canceled`, `refunded` ou `chargeback`. |
| `pix.copyPaste` | Código "copia e cola". Sempre presente em Pix. |
| `pix.qrCode` | Imagem do QR quando a gateway fornece; **vazio na Beehive e na FreePay** (gere o QR a partir do `copyPaste`). |
| `boleto.url` / `boleto.barcode` | Link e linha digitável (boleto). |
| `expiresAt` | Expiração, quando informada pela gateway. |
| `checkoutUrl` | Quando presente, redirecione o comprador para essa URL (ex.: cartão com autenticação). |

### Idempotência (evitar cobrar em dobro)

Mande o header **`Idempotency-Key`** em cada criação (recomendado: qualquer string única por requisição de checkout, ex. o id do pedido). Sem o header, o hub usa a `reference` do corpo como chave.

- Repetir **a mesma requisição** em até **24 h** devolve **a mesma cobrança**, sem criar outra; a resposta traz o header `x-idempotent-replay: 1`.
- Se a criação falhar, a chave é liberada: pode repetir sem medo, nada foi criado.
- A `reference` também trava 24 h: para gerar **outra** cobrança do mesmo pedido (ex. Pix expirado), mande um `Idempotency-Key` diferente.
- Sem header e sem `reference`, cada chamada cria uma cobrança nova (comportamento antigo).

## Gateway por funil

Use quando cada funil (projeto) deve cobrar por uma gateway diferente, e você quer trocar a gateway de um **sem mexer nos outros**.

1. **No painel:** aba *Funis*. Digite o nome do funil (slug, ex.: `funil1`), clique em **Adicionar funil**, escolha a gateway e clique em **Salvar alterações**. Sem salvar, a regra não vale.
2. **No projeto do funil:** coloque no `.env` `HUB_SLUG=funil1`.
3. **No código:** mande esse valor ao criar a cobrança, no corpo (`"funnel": process.env.HUB_SLUG`) ou no header `X-Hub-Funnel`. Se vierem os dois, vale o do corpo.

Regras:

- O slug usa `a-z`, `0-9`, `.`, `_`, `-` (até 60 caracteres) e precisa ser **idêntico** no painel e no `.env`. O hub ignora maiúsculas e espaços nas pontas, mas `funil_1` e `funil-1` são funis diferentes.
- Funil sem regra, sem slug ou com slug inválido usa a **gateway ativa**, sem erro.
- Trocar a gateway de um funil vale só para as **novas** cobranças dele. Cobranças antigas continuam consultáveis e seus webhooks seguem na gateway em que nasceram.
- Os avisos de método ou valor mínimo não aceito (422) e de falta de API key (503) valem para a gateway do funil.

## Consultar cobrança

`GET /api/v1/charges/{id}`

```bash
curl "$HUB_URL/api/v1/charges/velani_8f2a1c" -H "Authorization: Bearer $HUB_API_KEY"
```

Retorna o mesmo formato da criação, com o `status` atual lido direto na gateway. Funciona mesmo que a gateway ativa tenha sido trocada depois da criação.

Ids que começam com `ext_` são **vendas registradas por você** (veja [Vendas de outro checkout](#vendas-de-outro-checkout-sem-passar-pelo-hub)). Nesse caso não há gateway para consultar: o hub responde a partir do próprio rastreamento, com `source: "external"`.

Adicione `?events=1` para receber junto a **timeline da cobrança** (últimos 100 eventos): criação, webhook recebido/confirmado/repassado, mudanças de status, envio ao Meta/TikTok/Google, reconciliações e, nas vendas externas, uma linha de auditoria por chamada de ingestão.

```bash
curl "$HUB_URL/api/v1/charges/velani_8f2a1c?events=1" -H "Authorization: Bearer $HUB_API_KEY"
```

## Webhook (aviso de pagamento)

1. No painel do hub, preencha **Webhook do cliente** com a URL do seu projeto (HTTPS).
2. Quando uma cobrança mudar de status, o hub faz `POST` nessa URL.

O hub **confirma o status na API da gateway** antes de avisar, então o evento reflete o estado real.

### Corpo

```json
{ "event": "charge.updated", "charge": { "id": "velani_8f2a1c", "gateway": "velani", "status": "paid", "amount": 1990, "method": "pix", "reference": "pedido-123" } }
```

### Headers

| Header | Conteúdo |
|---|---|
| `x-hub-signature` | `sha256=` + HMAC-SHA256 (hex) do **corpo bruto** usando `HUB_WEBHOOK_SECRET`. |
| `x-hub-gateway` | Gateway de origem. |

### Validar a assinatura (Node)

```ts
import { createHmac, timingSafeEqual } from "crypto";

export function validHubSignature(rawBody: string, header: string | null, secret: string) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(header ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

> Use o corpo **bruto** (`await req.text()`), antes de fazer `JSON.parse`. Rejeite com `401` se a assinatura não bater.

### Resposta esperada

Responda **HTTP 2xx** rapidamente. Qualquer outro código é tratado como falha e a gateway tentará reenviar. O mesmo evento pode chegar mais de uma vez: processe de forma **idempotente** (ex.: ignore se o pedido já está pago).

### Evento de teste ("Testar agora" no painel)

O painel tem um botão **Testar agora** que manda um webhook de verdade à URL salva — mesmo header de assinatura e corpo no mesmo formato:

```json
{ "event": "webhook.test", "charge": { "id": "test_1a2b3c", "gateway": "test", "status": "pending", "amount": 100, "method": "pix" } }
```

Seu sistema pode simplesmente **responder 2xx e ignorar** (`event !== "charge.updated"`), mas processe-o idempotentemente, como qualquer outro. O teste prova: URL acessível, assinatura validável e resposta rápida — o resultado aparece no próprio painel (status + latência).

### Se o seu sistema ficar fora do ar

O hub não desiste do aviso: o repasse entra numa **fila de reenvio** com espera crescente (30 s, 1 min, 2 min, ... até 30 min, até 12 tentativas). Antes de cada reenvio o hub relê o status na gateway, então quando o seu sistema volta, recebe o status mais recente — vale o mesmo processamento idempotente. Depois das 12 tentativas o evento é marcado como abandonado (fica na timeline da cobrança, visível em `GET /api/v1/charges/{id}?events=1`); se a gateway reenviar o webhook original, o repasse acontece na hora.

Requisito: sua URL precisa responder 2xx **rápido** (sem processar dentro da própria resposta).

## Rastreamento (UTMs)

O hub registra de onde veio cada venda (substitui a Utmify). O painel mostra em **Rastreamento** a receita paga, as vendas e a conversão por campanha, origem, mídia, criativo e termo.

1. Cole o script em todas as páginas da loja e do checkout, antes do `</head>`. O snippet pronto, com o seu `data-tid` e a sua `data-pk`, está no painel:

   ```html
   <script src="https://SEU-HUB/t.js" data-tid="t…" data-pk="pk_…" async></script>
   ```

   Ele guarda no navegador um `visitorId` e a última origem com UTM (`utm_*`, `fbclid`, `gclid`, `ttclid`, `_fbp`/`_fbc`) e registra as visitas.
2. Ao criar a cobrança, envie o visitante:

   ```json
   { "amount": 4990, "method": "pix", "customer": { "…": "…" },
     "tracking": { "visitorId": "<window.HubTrack.visitorId>" } }
   ```

   Se o checkout fica em outro domínio, leve a origem no link com `window.HubTrack.decorate(urlDoCheckout)`, que acrescenta `hub_vid` e as UTMs. Seu backend também pode mandar `tracking.attribution` direto (`{ "utm_source": "…", "utm_campaign": "…" }`).
3. Quando a gateway confirma o pagamento, a venda vira **paga** no painel. Isso acontece mesmo sem URL de webhook configurada.

Eventos extras: `HubTrack.track("checkout_start" | "lead" | "add_to_cart")`, ou o atributo `data-hub-event="checkout_start"` em um botão.

**Sem `visitorId` (plano B):** se o seu backend mandar `ip` (IP do comprador) e `tracking.ua` (o user agent do navegador dele), o hub procura uma visita recente com o mesmo IP e navegador (até 30 min) ou só o mesmo IP (até 10 min). O IP que chega ao hub sem o campo `ip` é o do seu servidor, por isso ele não é usado nessa busca. Mandar o `visitorId` continua sendo o mais preciso.

### Vendas de outro checkout (sem passar pelo hub)

Se o pagamento não é criado pelo hub (você usa outro checkout), mande a venda para `POST /api/v1/tracking/sales`. Ela entra na receita, no ROAS/lucro, na atribuição por UTM e no Meta (Purchase), igual a uma cobrança do hub.

```bash
curl -X POST https://SEU-HUB/api/v1/tracking/sales \
  -H "Authorization: Bearer $HUB_API_KEY" -H "Content-Type: application/json" \
  -d '{"reference":"pedido-123","amount":4990,"status":"paid","method":"pix",
       "gateway":"appmax","visitorId":"<window.HubTrack.visitorId>",
       "items":[{"name":"Curso","price":4990}],
       "email":"maria@ex.com","phone":"11933332222"}'
```

| Campo | Obrigatório | O que é |
|---|---|---|
| `reference` | sim | Id do pedido no seu sistema. **É a chave de deduplicação.** |
| `amount` | sim | Centavos, inteiro (> 0). |
| `status` | sim | `paid`, `pending`, `refunded` ou `chargeback`. |
| `method` | sim | `pix`, `boleto` ou `card`. |
| `paidAt` | não | ISO de quando foi aprovada. Padrão: agora, quando `status=paid`. |
| `gateway` | não | Texto livre: quem processou a venda. Padrão: `external`. |
| `funnel` | não | Slug do funil (o mesmo de *Gateway por funil*), para filtrar o relatório. |
| `items` ou `product` | não | Produtos: `{ id?, name, price (centavos), quantity?, kind? }`. Sem `id`, o `name` vira o id. |
| `email`, `phone` | não | Do comprador. O hub guarda só o hash (Meta) e não devolve na resposta. |
| `ip`, `ua` | não | Plano B de atribuição (mesmas janelas acima). |
| `visitorId` | não | `window.HubTrack.visitorId`: caminho mais preciso. |
| `attribution` / `utm_*` | não | Origem enviada por você; tem prioridade sobre o último toque. As UTMs também são aceitas no topo do corpo. |

**Idempotência e mudança de status.** A mesma `reference` cai sempre na mesma venda: reenviar com o mesmo `status` devolve `200` com `duplicate: true` e não duplica nada. Enviar outro `status` **atualiza o mesmo registro** — o fluxo normal é `pending` na criação, `paid` na aprovação e `refunded`/`chargeback` depois. O `createdAt` da venda não muda num reenvio (o dia do relatório continua sendo o da primeira venda).

**Meta (Conversions API).** Quando a venda está `paid`, o hub envia um `Purchase` (uma única vez por venda, `event_id` = id da venda, deduplicado com o Pixel do navegador). Não há `InitiateCheckout` para venda externa: ela já aconteceu.

**Resposta** (`201` na primeira vez, `200` depois):

```json
{ "id": "ext_9f2c…", "reference": "pedido-123", "status": "paid", "amount": 4990, "method": "pix",
  "gateway": "appmax", "createdAt": "2026-11-06T17:00:00.000Z", "paidAt": "2026-11-06T17:00:00.000Z",
  "attribution": { "utm_source": "facebook" }, "matchedBy": "visitor", "duplicate": false }
```

Erros: `401` chave inválida ou cliente desativado; `422` campo faltando ou inválido (a mensagem diz o que corrigir); `429` limite de requisições (veja [Limites](#limites-de-requisições)); `500` falha ao gravar (repita a mesma requisição — é idempotente).

**Consulta e auditoria.** O `id` devolvido (`ext_...`) funciona em `GET /api/v1/charges/{id}`. Como a venda não existe em gateway nenhuma, o hub responde a partir do próprio rastreamento (com `source: "external"`) e, com `?events=1`, devolve a **timeline**: a criação, cada mudança de status e uma linha de auditoria `external.received` por chamada aceita — quando, qual chave (`hub_live_…` só o prefixo), a `reference` e o status. Nenhum dado do comprador entra nesse log.

### Servir pelo domínio da loja (recomendado)

Safari e iPhone apagam em até 7 dias (ou 24 h, quando a pessoa chega com `fbclid`) os cookies criados por script, e bloqueadores de anúncio barram domínios de rastreamento. Para a origem durar, sirva o rastreador **pelo domínio da loja**, com um proxy de `/c/*` para o hub. Assim o cookie `hub_vid` é gravado pelo servidor, no domínio e no IP da loja.

Use `/c/s.js` no lugar de `/t.js`, e o script envia os eventos para `/c/e` na mesma origem:

```html
<script src="/c/s.js" data-tid="t…" data-pk="pk_…" async></script>
```

- **Next.js** (`next.config.js`): `async rewrites() { return [{ source: "/c/:path*", destination: "https://SEU-HUB/c/:path*" }]; }`
- **Nginx**: `location /c/ { proxy_pass https://SEU-HUB/c/; proxy_set_header Host SEU-HUB; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }`
- **Cloudflare**: um Worker na rota `loja.com.br/c/*` que faz `fetch` para `https://SEU-HUB` + o mesmo caminho, repassando o corpo e o header `X-Forwarded-For` com o `CF-Connecting-IP`.

Repasse sempre o IP do visitante (`X-Forwarded-For`): ele é usado no plano B e no envio ao Meta. Um CNAME (`t.loja.com.br` apontando para o hub) não resolve o Safari, que trata como de terceiro o cookie de um subdomínio com IP diferente do site.

Sem proxy, o `/t.js` continua funcionando como antes, só com a duração menor no Safari.

### Meta (Conversions API)

Em **Rastreamento → Meta**, cole o **Pixel ID** e um **token de acesso** da API de Conversões. A partir daí o hub envia ao Meta, direto do servidor:

| Quando | Evento | `event_id` |
|---|---|---|
| A cobrança é criada (Pix/boleto gerado) | `InitiateCheckout` | `<id da cobrança>:ic` |
| A gateway confirma o pagamento | `Purchase` (valor em BRL) | `<id da cobrança>` |

- Os dados do comprador vão em SHA-256, como a Meta exige: e-mail, telefone, nome, documento como `external_id`, e cidade, UF e CEP quando houver. Também vão IP, user agent e `fbp`/`fbc`. O hub guarda só os hashes, nunca o texto.
- Se você também dispara o Pixel no navegador, use `HubTrack.checkout(idDaCobranca, valorEmReais)` quando o Pix/boleto for gerado e `HubTrack.purchase(idDaCobranca, valorEmReais)` na página de obrigado. Eles chamam o `fbq` com o mesmo `eventID` que o hub manda pela API, e a Meta não conta duas vezes.
- Cada evento é aceito uma vez; a coluna **Meta** de "Últimas cobranças" mostra se foi aceito ou o erro.
- **Falhou?** O hub tenta de novo sozinho: ao abrir o painel (até 3 vezes, com espera de 15 min × tentativas) e no cron diário, enquanto o evento tiver menos de 7 dias (limite da Meta). Corrigiu o Pixel ou o token? Os eventos com erro são reenviados no próximo cron.
- Para validar, preencha o **código de teste** (Gerenciador de Eventos → Eventos de teste) e apague depois.

### Gasto, ROAS e lucro (Meta, Google e TikTok)

O hub importa o gasto das três redes e soma tudo no mesmo relatório. Cada uma é configurada no seu card do **Rastreamento**:

| Rede | O que configurar | Como é lido |
|---|---|---|
| **Meta Ads** | **ID da conta** (`act_…`) + token com `ads_read` (pode ser um token separado do de Conversões) | Insights por anúncio/dia |
| **Google Ads** | **Customer ID** (10 dígitos), **developer token** e o OAuth das conversões (client id, client secret, refresh token) | GAQL (`searchStream`): `cost_micros` por anúncio/dia |
| **TikTok Ads** | **Advertiser ID** (Advertising Manager) + o access token já usado nas conversões | Reporting API integrada, por anúncio/dia |

- **Sozinho:** ao abrir o painel, se a última leitura tiver mais de 1 hora, e uma vez por dia pelo cron (relê os 3 últimos dias, que as plataformas ainda corrigem).
- **Na hora:** pelo botão **Sincronizar 30 dias** (use na primeira vez). A janela vai até 90 dias.
- **Uma rede fora do ar não apaga nada:** as linhas daquela rede ficam como estavam e as outras continuam sendo gravadas. O erro aparece por rede no painel.
- **Google sem escopo/credencial** responde com uma mensagem dizendo o que conferir (o token precisa de acesso à conta e o developer token precisa estar aprovado).

Em **Custos**, informe a taxa da gateway (%), a taxa fixa por venda (R$), os impostos (%) e o custo do produto (%). O painel mostra:

| Métrica | Conta |
|---|---|
| ROAS | receita paga ÷ gasto |
| CPA | gasto ÷ vendas pagas |
| Custos | receita × (taxa % + impostos % + produto %) + taxa fixa × vendas pagas |
| Lucro | receita − gasto − custos |

O gasto entra em cada linha da tabela quando o agrupamento é **Campanha**, **Conjunto** ou **Criativo** e a UTM leva o id do Meta (`nome|{{campaign.id}}`, `{{adset.id}}`, `{{ad.id}}`, como no modelo de UTM acima). Sem o id, o hub tenta casar pelo nome. Campanhas que gastaram e não venderam aparecem com lucro negativo. Nos demais agrupamentos, o gasto entra só no total.

O payload do painel traz **`spendByNetwork`** (`{ meta, google, tiktok }`, em centavos) e a soma dos três é exatamente o `totals.spend`. O gasto de uma conta que **não está em BRL** não é somado (o hub não converte moeda) e o painel avisa qual rede ficou de fora. Os dias do painel seguem o **fuso da conta de anúncios**, lido na sincronização (padrão: America/Sao_Paulo), para o dia das vendas bater com o do gasto.

**Sem conectar a API?** Use a importação manual (`POST /api/panel/tracking/spend-import`, veja abaixo).

### Importar gasto manualmente (CSV ou JSON)

Para quem não conecta a API da rede: `POST /api/panel/tracking/spend-import` com a sessão do painel.

```bash
curl -X POST "$HUB_URL/api/panel/tracking/spend-import" -H "Content-Type: text/csv" --data-binary @gasto.csv
```

```csv
date,network,campaignId,campaignName,spend,currency
2026-11-01,google,1234567890,Campanha de busca,4990,BRL
2026-11-01,tiktok,1789...,Criativo TikTok,2500,BRL
```

| Coluna | Obrigatória | O que é |
|---|---|---|
| `date` | sim | Dia do gasto (`YYYY-MM-DD`). |
| `network` | não | `meta`, `google` ou `tiktok`. Ausente = `meta`. |
| `campaignId` | sim | Id da campanha na rede (é o que liga a campanha ao funil). |
| `campaignName` | não | Nome exibido. Ausente = o id. |
| `spend` | sim | **Centavos inteiros** (`4990` = R$ 49,90). |
| `currency` | não | Moeda da conta. Ausente = BRL. Diferente de BRL é gravado, mas não somado. |

- Também aceita JSON: `{ "rows": [ { "date": "2026-11-01", "network": "google", "campaignId": "…", "spend": 4990 } ] }`.
- O CSV aceita `,` ou `;` (Excel em português) e campos entre aspas. Até 5000 linhas por envio.
- Reenviar o mesmo arquivo **não duplica**: cada (rede, dia) substitui o lote anterior daquela rede naquele dia, e as outras redes não são afetadas.
- Se a rede estiver conectada por API, a sincronização automática volta a sobrescrever aquele dia. A importação é o **fallback**.

### ROAS por funil

Cada funil pode ter uma ou várias campanhas, **de qualquer rede**. Em **Rastreamento → Funis das campanhas**, escolha o funil de cada campanha — o hub já sugere o que consegue deduzir pela UTM.

- **Com mapeamento:** todo o gasto da campanha entra no funil escolhido.
- **Sem mapeamento:** o hub deduz o funil pela `utm_campaign` das vendas da campanha (`nome|{{campaign.id}}`), usando o funil que mais gerou receita paga naquela campanha. Essa dedução só vale para o Meta (a UTM carrega o id do Meta).
- **Sobrou gasto:** campanha de Google/TikTok sem mapeamento aparece como **gasto não atribuído** (`unmappedSpend` no payload) — nunca é distribuído no chute.

O painel recebe `byFunnel` (uma linha por funil: receita, gasto, lucro, ROAS e vendas pagas) e `unmappedSpend`. ROAS é `null` quando o funil não tem gasto atribuído (não é "infinito"). Com `?funnel=` no relatório, os totais usam a receita **e o gasto** daquele funil.

`GET /api/panel/tracking/funnels` devolve o mapa e os funis cadastrados; `PUT` com `{ "campaignFunnels": { "<id da campanha>": "<slug>" } }` substitui o mapa inteiro (`{}` apaga). Para campanhas fora do Meta, use a chave com a rede: `"google:1234567890"` ou `"tiktok:1789…"` — ids iguais de redes diferentes não se misturam, e a chave só com número continua valendo para o Meta.

### Funil por etapa

O painel mostra quantas pessoas passaram por cada etapa: **visitas → checkout iniciado → lead → cobranças criadas → vendas pagas**, com a taxa de cada etapa contra a anterior.

- O hub guarda só **contagens por dia** (por campanha, origem, mídia, criativo, termo e funil), com 90 dias de retenção. Nenhum evento individual e nenhum dado do comprador é gravado.
- As etapas de navegação vêm do rastreador (`pageview`, `checkout_start` via `HubTrack.track("checkout_start")` ou `HubTrack.checkout(...)`, e `lead`). As etapas de cobrança vêm da própria API: `charge_created` quando a cobrança é criada e `paid` quando a gateway confirma o pagamento (webhook ou reconciliação).
- Quem começou a rastrear depois vê as etapas antigas zeradas — o hub não inventa número.
- O dia do contador segue o fuso do rastreamento (America/Sao_Paulo), o mesmo das visitas.
- `?funnel=` no relatório filtra: visitas/checkout/lead pelas campanhas mapeadas ao funil, cobranças/vendas pelo funil em que foram criadas.

No payload, `funnelSteps` traz as cinco etapas (`key`, `label`, `count`, `rate`) e cada linha de UTM pode trazer `steps` com os mesmos contadores.

### Modelo de atribuição

Por padrão a venda fica com o **último toque** (comportamento histórico). Dá para escolher em **Rastreamento → Atribuição**:

| Modelo | Como a venda é atribuída |
|---|---|
| `last` (padrão) | Último toque com origem antes da cobrança. |
| `first` | Primeiro toque com origem do visitante (o que o rastreador gravou na primeira visita). |
| `linear` | Receita dividida igualmente entre os toques **distintos** dentro da janela. Hoje o hub guarda o primeiro e o último toque: se forem o mesmo, é um toque só. |

- **Janela** (`windowDays`, 1 a 90 dias, padrão 30): toque mais antigo que isso não conta e a venda cai em **"(sem origem)"**.
- No modelo `linear` a divisão é feita em **centavos inteiros** (maior resto): a soma das partes é sempre o valor da venda — R$ 9,99 vira 5,00 + 4,99, nunca 4,99 + 4,99. A venda é contada uma vez na tabela (na maior parte); cada linha recebe a sua fatia de receita.
- `GET /api/panel/tracking/attribution` devolve `{ model, windowDays }` e as opções; `PUT` com `{ "model": "linear", "windowDays": 30 }` salva. O relatório devolve o modelo em uso no campo `attribution`.

### Lucro real (líquido, lucro, ROAS, ROI e margem)

O relatório calcula tudo em centavos e devolve os componentes separados em `totals`, `rows`, `daily` e `byFunnel`:

```
líquido (net)   = vendas pagas − taxa da gateway − coprodutor/afiliado − imposto
                  − custo do produto − multa de chargeback
imposto do gasto = gasto com anúncios × adsTaxPct
lucro (profit)  = líquido − gasto com anúncios − imposto do gasto − despesas
costs           = taxas + afiliado + produto + imposto + chargeback + imposto do gasto
ROAS            = receita bruta ÷ gasto com anúncios        (N/A sem gasto)
ROI             = líquido ÷ (gasto + imposto do gasto + despesas)   (N/A sem investimento)
margem          = lucro ÷ líquido                            (N/A só quando o líquido é zero)
```

- **Custo por venda:** taxa da gateway (percentual + fixa, com taxa própria por gateway), **coprodutor/afiliado** (%), **imposto** sobre a receita (%), **custo do produto** (% e fixo) e multa de chargeback. O custo de produto e a comissão podem ser definidos **por produto** (`byProduct`, chave = id do item da cobrança).
- **Imposto sobre o gasto** (`adsTaxPct`): incide sobre o gasto com anúncios (ex.: imposto retido pela plataforma) e entra no investimento do ROI.
- **Despesas adicionais** (editor, ferramenta, aluguel): cadastradas em **Rastreamento → Despesas**, podem ser `daily`, `monthly` (÷30) ou `once` (numa data). São rateadas nos dias do período e, na tabela por origem/funil, pela receita de cada linha — a soma da coluna de lucro fecha com o lucro total.
- Nenhuma divisão devolve `Infinity`: quando o denominador é zero, o campo vem `null` (N/A).

`PUT /api/panel/tracking` com `{ "costs": { ... } }` salva os custos (aceita `feePct`, `feeFixed`, `taxPct`, `productPct`, `productFixed`, `affiliatePct`, `adsTaxPct`, `otherFees`, `chargebackFee`, `byGateway` e `byProduct`).

**Despesas:** `GET /api/panel/tracking/expenses` (lista + total dos últimos 30 dias), `POST` (cria), `POST`/`PUT` em `/api/panel/tracking/expenses/<id>` (edita) e `DELETE` (apaga). Corpo: `{ "name": "Editor", "amount": 50000, "period": "monthly" }` (`amount` em centavos; `once` exige `date`).

**Filtros do relatório** (`?account=`, `?source=`, `?platform=`, `?product=` em `GET /api/panel/tracking`):

- `source`, `product` e `platform` filtram **receita e gasto na mesma dimensão**. O `platform` da venda é inferido pelo identificador de clique (`gclid` → Google, `ttclid` → TikTok, `fbclid`/`fbp`/`fbc` → Meta); venda sem identificador não é atribuível a nenhuma rede e fica de fora do filtro de plataforma. Com isso ROAS/ROI/margem continuam comparando receita e gasto da mesma rede.
- `account` filtra **só o gasto** (a venda não guarda conta de anúncios). Nesse caso os índices que cruzam receita e gasto vêm `null` (não compare receita total com gasto filtrado) e `filtersMeta.spendOnly` diz o que foi aplicado.
- **Gasto sem conta identificada** (importação manual) não desaparece com filtro de conta: entra no total e aparece em `filtersMeta.spendWithoutAccount`.
- `byFunnel` inclui a linha **"Não atribuído"** quando há gasto de campanha sem funil mapeado, para a soma do gasto por funil fechar com o total (o valor continua em `unmappedSpend`).

### Listagens: pedidos e eventos de conversão

- `GET /api/panel/orders?from=YYYY-MM-DD&to=YYYY-MM-DD&status=paid&source=hub|external&platform=<slug>&q=<busca>&limit=50&offset=0` → `{ orders, total, hasMore }`. Cada pedido traz `id`, `reference`, `status`, `amount`, `method`, `gateway`, `platform` (venda externa), `source`, `funnel`, `createdAt`, `paidAt` e as UTMs. **Sem dados pessoais.**
- `GET /api/panel/pixel-events?from&to&network=meta|tiktok|google&event=Purchase&status=sent|error|pending|disabled&limit&offset` → `{ events, total, hasMore }`. Uma linha por evento enviado (InitiateCheckout/Purchase do Meta, CompletePayment do TikTok, conversão do Google), com o pixel configurado, o `event_id` (dedupe) e o erro quando a rede recusou.
- `GET /api/panel/tracking/events?from&to` → `{ steps }`: o mesmo funil por etapa do relatório, numa rota só dele.

### Webhooks de plataformas de checkout

Estrutura pronta para receber a venda de plataformas de terceiros (Hubla, Kiwify, Hotmart, Ticto, Perfect Pay, Eduzz). Configure em **Rastreamento → Plataformas** e use a URL mostrada:

```
POST /api/webhooks/platform/<slug>?t=<id do cliente>

x-platform-signature: sha256=<HMAC-SHA256 do corpo bruto com o segredo>
x-platform-timestamp: <epoch em segundos>          (opcional, recomendado)
```

- **Prefira a assinatura HMAC** (`x-platform-signature`). Com `x-platform-timestamp`, o HMAC é calculado sobre `"<timestamp>.<corpo>"` e o hub só aceita ±5 minutos — isso impede reapresentar um corpo antigo (replay). Sem o timestamp, o HMAC é sobre o corpo bruto.
- `x-platform-token: <segredo>` continua aceito para plataformas que só sabem mandar um token fixo (comparação em tempo constante). É o caminho mais fraco: use só se a plataforma não assinar.
- **Segredo:** 32 a 128 caracteres de `A-Z a-z 0-9 _ -`. O painel pode gerar um (`generate: true`, mostrado uma única vez). Segredo mais curto é recusado ao salvar.
- **Ordem das checagens:** tamanho (413 antes de ler) → limite de requisições (429) → cliente/plataforma (404) → plataforma ligada (403) → assinatura (401) → corpo.
- **Respostas:** `201` criada, `200` reenvio idempotente, `401` assinatura ausente/inválida, `403` plataforma desligada no painel, `404` cliente ou plataforma inexistente (resposta uniforme: não confirma se o cliente existe), `413` corpo acima de 64 KB, `429` limite de requisições, `501` adaptador ainda não implementado.
- **Limites:** corpo de até **64 KB**; **60 requisições/min por cliente** e **120/min por IP** (env `HUB_RATE_LIMIT_WEBHOOKS_PER_MIN`).
- **Status monotônico:** depois de aprovada, a venda não volta para pendente. Um webhook atrasado (`pending`) é registrado na timeline como ignorado e não derruba a receita nem apaga o `Purchase`. Reembolso e chargeback continuam valendo.
- **`paidAt`:** precisa ser ISO, não pode estar no futuro (tolerância de 5 min) nem mais de **7 dias** no passado; fora disso é ignorado e vale o horário de chegada.
- **Hoje só o adaptador `generic` está implementado** (aceita o mesmo corpo do `POST /api/v1/tracking/sales`). Os das plataformas reais respondem **501** até o formato ser confirmado — o hub nunca grava venda a partir de um adaptador não implementado.
- A venda entra pelo mesmo caminho da venda externa (dedupe por `reference`, atribuição, Meta/TikTok/Google e timeline), com a plataforma gravada em `gateway` (aparece em `/api/panel/orders`).

### Webhooks das gateways: limites e assinatura

O webhook que a gateway chama (`/api/webhooks/<cliente>/<gateway>`) também é público:

- corpo de até **64 KB** (`413` acima disso) e limite de **120 req/min por IP** e **60 req/min por cliente+gateway** (`429`);
- a **Velani exige o `whsec_` configurado**: sem ele o evento é recusado com `401`. Um POST anônimo dispararia consulta paga na API dela e repasse ao seu sistema;
- Beehive, Pingu e FreePay não assinam: o hub continua **confirmando o status na API da gateway** antes de repassar, então um corpo forjado não marca pedido como pago.

### Rastreamento no navegador: atribuição e privacidade

O `/t.js` (e o `/c/s.js` atrás de proxy) manda o evento para `/c/e`:

- **IP de origem** é lido só de cabeçalho de proxy confiável (`x-vercel-forwarded-for`, depois `x-real-ip`, depois o ÚLTIMO salto do `x-forwarded-for`) — nunca da primeira posição do XFF, que o cliente controla. O IP indexa a visita apenas para o "plano B" da atribuição.
- **`hub_vid` na URL** (link decorado por `HubTrack.decorate`) só é aceito quando combina com um visitante **já conhecido** do hub e com o mesmo navegador; caso contrário o evento entra **sem origem** (um link de terceiro não injeta a UTM dele na sua venda).
- **CORS**: a resposta não usa credenciais e não reflete o `Origin` (`access-control-allow-origin: *`, sem `allow-credentials`). O `visitorId` do próprio script continua no cookie/localStorage da loja.
- Uma venda **aprovada não volta a pendente** por webhook atrasado, e `paidAt` só é aceito entre 7 dias atrás e agora.

### TikTok (Events API)

Em **Rastreamento → TikTok**, cole o **Pixel code** e um **access token** do Events API (Gerenciador de Eventos do TikTok → API). O **Advertiser ID** (Advertising Manager) é opcional: só é usado para importar o gasto. Quando a venda é paga, o hub envia `CompletePayment` com:

- o `ttclid` do navegador quando existir (é o identificador mais forte);
- e-mail e telefone em **SHA-256** (o mesmo hash do Meta), `external_id` (documento), IP e user agent;
- `event_id` = id da venda — reenviar não conta duas vezes.

O hub não envia o evento quando a venda não tem **nenhum** identificador (`ttclid`, e-mail ou telefone): não casaria e só gastaria quota. O **código de teste** é opcional (aparece em Eventos de teste).

### Google Ads (conversões offline)

Em **Rastreamento → Google Ads**, informe o **Customer ID** (10 dígitos, com ou sem traços), a **conversion action** (o id numérico ou `customers/<id>/conversionActions/<id>`) e o acesso OAuth: **client id**, **client secret** e **refresh token**, além do **developer token**. Se a conta está dentro de um MCC, informe também o **login customer id**.

As mesmas credenciais servem para importar o **gasto** da conta (a conversion action não é necessária para isso).

Quando a venda é paga e tem `gclid`, o hub chama `uploadClickConversions` com valor em reais, moeda BRL, a data da aprovação no fuso configurado e `orderId` = id da venda (o Google não conta o mesmo pedido duas vezes). Sem `gclid` nada é enviado.

### Retry e isolamento das conversões

| Rede | Estado na venda | Reenvio |
|---|---|---|
| Meta | `capi` | painel (3 tentativas, 15 min × tentativas) e cron diário, até 7 dias |
| TikTok | `tiktok.sentAt` / `tiktok.error` | mesmo esquema |
| Google Ads | `google.sentAt` / `google.error` | mesmo esquema |

Uma rede que falha **não impede as outras**: cada envio é independente e a timeline da cobrança registra `tiktok.sent`, `tiktok.error`, `google.sent` e `google.error`. Nenhum token aparece em log, resposta ou mensagem de erro.

### Produtos, aprovação, reembolsos e período

- **Produtos e order bump:** mande `items` na cobrança (veja a tabela do corpo). O painel mostra cobranças, pagas, conversão e receita por produto, e a **taxa de order bump**: das vendas pagas com produto principal, quantas levaram bump. Os itens também vão para a Meta no `Purchase` (`content_ids`, `contents`, `num_items`).
- **Aprovação:** cobranças geradas × pagas por método (Pix, boleto, cartão) e por gateway.
- **Reembolso e chargeback:** quando a gateway avisa, a venda sai da receita e aparece em "Reembolsos e chargebacks", com a taxa de cada um (o hub alerta quando o chargeback passa de 1%).
- **Custos:** além da taxa padrão, informe a **taxa própria de cada gateway** (% e fixo) e a **multa por chargeback**. O lucro usa a taxa da gateway que processou cada venda.
- **Contas de anúncio:** informe várias, separadas por vírgula (até 10). O gasto soma todas, e a moeda e o fuso são os da primeira.
- **Período:** além de hoje/7/30/90 dias, escolha **de/até** (até 366 dias). Os cartões comparam com o período anterior de mesmo tamanho. A tabela por agrupamento sai em **CSV** pelo botão Exportar.

### Avisos de venda no celular (Telegram)

Em **Rastreamento → Avisos de venda**, conecte um bot do Telegram seu. Cada venda chega no celular na hora em que a gateway confirma, inclusive no iPhone.

1. No Telegram, fale com o **@BotFather**, envie `/newbot` e cole o token no painel.
2. Abra o bot (ou adicione-o a um grupo da equipe) e envie `/start`.
3. Clique em **Conectar** e depois em **Enviar teste**.

Você escolhe o que avisar: **venda aprovada**, **reembolso** e **chargeback** (ligados por padrão) e **Pix/boleto gerado** (desligado). O aviso traz valor, método, produto, order bump e a origem (UTM). Nenhum dado pessoal do comprador vai na mensagem. Cada mudança de status avisa uma vez, mesmo que a gateway repita o webhook.

### Alerta de gateway fora (operacional)

No mesmo chat, com o mesmo bot, o hub também avisa **quando uma gateway de pagamento para de responder**: 3 falhas de comunicação seguidas (criação, confirmação na API, reenvio ou reconciliação) geram um aviso `_🚨 Gateway fora do ar_` com o último erro — **um só aviso por período de queda**, sem spam. Quando a gateway volta a responder, chega `_✅ Gateway normalizada_`. Ligue/desligue no painel de Avisos (card "Alertas operacionais"; padrão: ligado). Falha do webhook do SEU sistema não dispara esse alerta — o alerta é sobre a gateway, não sobre sua URL.

### Painel no celular (instalar como app)

No celular, abra o painel no navegador e use **Adicionar à tela inicial** (Chrome: menu ⋮ → Instalar app; Safari: Compartilhar → Adicionar à Tela de Início). Ele abre direto no Rastreamento, em tela cheia, como um app.

### Pausar, ativar e mudar orçamento

Agrupando por **Campanha**, **Conjunto** ou **Criativo**, a tabela mostra o status e o orçamento diário que estão no Meta, com os botões **Pausar** / **Ativar** e **Orçamento**. O token precisa da permissão `ads_management` (ler o gasto pede só `ads_read`).

- O hub só age em campanhas, conjuntos e anúncios que existem na conta de anúncios configurada.
- O orçamento só muda em quem tem **orçamento diário próprio**: na campanha com orçamento de campanha, no conjunto sem ele. O mínimo é R$ 1,00.
- Tudo fica em **Histórico de ações**: quando, quem (painel, suporte ou regra), o que mudou (de → para) e o resultado ou erro do Meta.

### Regras automáticas

Regras pausam, reativam ou mudam o orçamento sozinhas, a partir do gasto e das **vendas reais do hub** (não das conversões que o Meta reporta).

- **Como funciona uma regra:** cada uma tem nível (campanhas, conjuntos ou anúncios), período (hoje, 3 ou 7 dias), até 4 condições que precisam valer juntas, uma ação e uma espera.
  - **Condições:** gasto, receita, lucro, vendas pagas, cobranças, ROAS ou CPA.
  - **Ação:** pausar, ativar ou mudar o orçamento de −90% a +100%.
  - **Espera:** depois de agir numa entidade, a regra não age nela de novo antes de N horas.
- **Como as métricas são calculadas:** ROAS sem gasto não conta; CPA com gasto e nenhuma venda conta como infinito (então "CPA > X" pega quem gastou e não vendeu).
- **Modelos prontos:** "Pausar sem venda", "Pausar CPA alto" e "Escalar vencedora".
- **Antes de ligar:** use **Simular**, que mostra o que a regra faria agora sem alterar nada.
- **Chave geral:** com "Desligar regras", nenhuma regra roda.
- **Limite de segurança:** no máximo 20 ações por execução.

**Quando rodam:** no cron diário da Vercel (o mesmo do gasto) e pelo botão **Rodar agora**. Para rodar a cada 15–30 min, que é o que faz sentido para regras de "hoje", use um agendador externo chamando:

```bash
curl -X POST https://SEU-HUB/api/cron/rules -H "Authorization: Bearer $CRON_SECRET"
```

Pode ser o QStash da Upstash (o mesmo provedor do banco), o cron-job.org ou um cron do plano Pro da Vercel. Depois de configurar, defina `RULES_SCHEDULER=external` na Vercel para o painel parar de avisar que as regras rodam só uma vez por dia.

**Cuidado com regras que se contradizem** (uma pausa, outra reativa a mesma campanha): cada regra respeita a própria espera, então elas podem se alternar a cada ciclo.

UTMs sugeridas para o Meta Ads (copie no painel):
`utm_source=facebook&utm_medium={{placement}}&utm_campaign={{campaign.name}}|{{campaign.id}}&utm_content={{ad.name}}|{{ad.id}}&utm_term={{adset.name}}|{{adset.id}}`.
Os ids servem para cruzar com o gasto dos anúncios quando essa etapa entrar.

## Erros

Todas as falhas retornam `{ "error": "mensagem" }`.

| HTTP | Significado |
|---|---|
| `400` | Corpo inválido ou campo obrigatório ausente. |
| `401` | Chave ausente, inválida ou cliente desativado. |
| `404` | Cobrança não encontrada. |
| `422` | Pedido recusado: método não suportado pela gateway ativa, valor abaixo do mínimo, dado obrigatório ausente (documento, telefone, ip) ou recusa da gateway. A mensagem explica o motivo. |
| `429` | Limite de requisições por cliente (veja abaixo). Espere o `Retry-After`. |
| `502` | A gateway não respondeu ou deu erro. Pode tentar de novo. |
| `503` | A gateway ativa está sem API key configurada no painel. |

## Limites de requisições

Cada cliente tem **120 requisições por minuto** em cada endpoint de escrita: `POST /api/v1/charges` e `POST /api/v1/tracking/sales`. Os dois têm orçamentos separados — um pico de cobranças não bloqueia a ingestão de vendas (e vice-versa).

- A janela é **deslizante**: cada requisição conta por 60 s a partir de quando chegou.
- Ao passar do limite, a resposta é **`429`** com `Retry-After` (segundos) e `X-RateLimit-Limit` / `X-RateLimit-Remaining`. Repita depois desse tempo; o corpo diz quantos segundos faltam.
- A leitura (`GET /api/v1/charges/{id}`), o webhook para o seu sistema e o rastreador (`/t.js`, `/c/e`) **não** entram nesse limite.
- Quem decide o valor é o dono do hub (variável `HUB_RATE_LIMIT_PER_MIN` no servidor; padrão 120). O seu sistema não precisa configurar nada.

## Diferenças entre gateways

| | Beehive | Velani | Pingu | Pague-X | FreePay |
|---|---|---|---|---|---|
| Métodos | pix, boleto, card | pix | pix | pix, boleto, card¹ | pix, boleto, card |
| Valor mínimo | – | R$ 5,00 | R$ 1,00 | boleto: R$ 5,00 | R$ 1,00² |
| `customer.document` | obrigatório | opcional | obrigatório | obrigatório | obrigatório |
| `customer.phone` | opcional | opcional | obrigatório | opcional | obrigatório |
| `customer.address` | – | – | – | boleto | – |
| `ip` do comprador | obrigatório | – | – | – | recomendado |
| `pix.qrCode` (imagem) | vazio | sim | sim | sim | vazio |
| Idempotência por `reference` | – | sim | sim | **não** | – |

¹ Cartão e boleto na Pague-X exigem adquirente habilitada na conta; senão a gateway recusa.
² Mínimo conservador do hub (a doc da FreePay não informa).

Se o seu sistema aceita vários métodos, trate o `422` e ofereça ao cliente os métodos que a gateway ativa suporta.

## Boas práticas

- Guarde o `id` retornado junto ao pedido.
- Não dependa só do webhook: consulte `GET /charges/{id}` como reserva (por exemplo, quando o cliente voltar à página).
- Gere uma `reference` única por pedido. Reenviar o mesmo pedido com a mesma `reference` evita cobrança em duplicidade na Velani e na Pingu. **Na Pague-X cada chamada cria uma cobrança nova**: em caso de timeout, consulte antes de reenviar.
- Chame o hub **sempre do servidor**.
- Defina um timeout (ex.: 25 s) na chamada; as gateways podem demorar.

## Capturar IP e token de cartão

**IP do comprador (`ip`).** Envie o IP do *visitante*, não o do seu servidor. Atrás de proxy/CDN, ele vem no primeiro valor do header `x-forwarded-for`:

```ts
// Next.js / Node (rota do seu servidor)
const buyerIp = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
```

**Cartão (`cardToken`).** O hub nunca recebe número, validade nem CVV. O formulário de cartão fica no **navegador** do seu cliente, que usa o SDK da gateway ativa para gerar um token/hash de uso único (curta duração), e o seu servidor envia só esse token em `cardToken`:

| Gateway | Como gerar o token |
|---|---|
| Beehive | `BeehivePay.encrypt(...)` (script `https://api.conta.paybeehive.com.br/v1/js`), usa o hash em `cardToken`. |
| Pague-X | Hosted Fields / SDK `paguex.js`, usa `card.token`. |
| FreePay | `encrypt()` do SDK da FreePay (ver "Cartão com 3DS" na doc dela). |

Como o SDK depende da gateway ativa, só ofereça cartão se o seu sistema souber qual está ativa (ou fixe uma gateway para cartão). Quando a resposta trouxer `checkoutUrl`, redirecione o comprador.

## Exemplo em Python

```python
import os, requests

def criar_cobranca(valor_centavos, pedido, cliente, ip):
    r = requests.post(
        f"{os.environ['HUB_URL']}/api/v1/charges",
        headers={"Authorization": f"Bearer {os.environ['HUB_API_KEY']}"},
        json={
            "amount": valor_centavos, "method": "pix", "reference": pedido,
            "funnel": os.environ.get("HUB_SLUG"),  # funil deste projeto (opcional)
            "customer": cliente,  # {"name","email","document","phone"}
            "ip": ip,
        },
        timeout=25,
    )
    r.raise_for_status()  # 422 traz {"error": "..."} explicando o que faltou
    return r.json()       # guarde r.json()["id"]
```

Validar o webhook em Python:

```python
import hmac, hashlib

def assinatura_valida(corpo_bruto: bytes, header: str, segredo: str) -> bool:
    esperado = "sha256=" + hmac.new(segredo.encode(), corpo_bruto, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperado, header or "")
```

## Usar com uma IA

Para uma IA gerar o código de integração, entregue um destes (do mais simples ao mais completo):

1. **Este guia em texto puro:** `/docs.md` (ou o botão **⬇ Baixar .md** no topo da página) e cole no chat.
2. **A URL** `/llms.txt`, para ferramentas que leem esse índice.
3. **O contrato OpenAPI:** `/openapi.json`, para Cursor, ChatGPT/Actions e geradores de cliente.

Prompt sugerido:

> Usando a API do documento anexo, crie o serviço de pagamentos no meu projeto (informe a linguagem/framework): função para criar cobrança Pix, função para consultar por `id`, e uma rota de webhook que valide `x-hub-signature` com o corpo bruto e marque o pedido como pago quando `charge.status === "paid"` (idempotente). Use as variáveis `HUB_URL`, `HUB_API_KEY`, `HUB_SLUG` (mande o valor em `funnel` ao criar a cobrança; ou no header `X-Hub-Funnel`) e `HUB_WEBHOOK_SECRET`; nunca exponha a chave no front.

Revise o que a IA gerar e teste com a gateway **Simulado (teste)** antes de usar uma gateway real.
