Ir para o conteúdo

ClickBank

O ClickBank é a primeira rede de afiliados integrada à Brandvelia, mas a arquitetura é desenhada para ser desacoplada e extensível a outras redes (INS, MarketHealth, Stevemcd, Brainborn, etc.) e aos produtos próprios do operador.


O que está integrado

Tudo abaixo vive em packages/integrations/src/clickbank/:

Capacidade Adaptador
Link builder ClickBankLinkBuilder / buildClickBankLink — HopLinks com extclid (click id único) + parâmetros nomeados (campaign, adgroup, ad, creative) espelhados em aff_sub1-5, mais traffic_source e traffic_type (snake_case, os nomes oficiais do HopLink; o ClickBank descarta trafficSource/trafficType).
INS 8.0 decryptInsNotification (AES-256-CBC, key = primeiros 32 chars do SHA-1(secret)), normalizeInsOrder, insDedupKey, insTrackingCodes, insCampaignName, parseInsTimestamp.
QuickStats ClickBankQuickStatsClient (listAccounts, listDaily, count) — requer CLICKBANK_API_KEY_1 (chave única API Order Read, criada em API Management; _2 é opcional para uma 2ª conta). O par antigo dev:clerk retorna 401.
Feed de marketplace fetchSampleMarketplaceCatalog — catálogo de amostra estático (14 itens, source: sample_catalog) usado apenas como seed/demo do Radar.
Opportunity Score calculateOpportunityScore — score determinístico 0–100 que alimenta o Radar.

Webhook → atribuição

Webhooks estilo ClickBank (POST /api/webhooks/clickbank) movem todo o funil:

  1. O body bruto é decriptado/decodificado do INS (decryptInsNotification).
  2. insTrackingCodes(order) resolve o click_id até uma linha de bv_visits.
  3. Essa visita aponta para uma landing page → campanha → oferta, tudo dentro do workspace.

Atribuição venda → campanha (determinística, no SQL)

A ingestão (brandvelia.ingest_clickbank_event_v2) chama resolve_clickbank_sale_context:

  • Campanha, nesta ordem: ID da campanha Google via utm_campaign={campaignid} da visita (exatamente 1 mapping GOOGLE ACTIVE, campanha não arquivada) → landing_pages.campaign_id (não arquivada) → nome de campanha do INS (único, sem arquivadas) → pack GOOGLE READY da pressel (prefere campanha já vista no Google).
  • Oferta: da pressel → da campanha resolvida → pelo vendor, ignorando ofertas arquivadas; empate só é desfeito pela oferta com campanha ENABLED no Google.
  • Ambiguidade = nulo, nunca chute. A origem fica no audit_log (attribution.campaign_source/offer_source).
  • Se o resolvedor falhar, o bloco legado assume e grava attribution.error = 'RESOLVER_FAILED': uma falha de atribuição nunca marca a venda como FAILED.
  • Salvar os anúncios (passo ADS do lançamento) vincula a pressel à campanha; um vínculo com campanha arquivada ou rascunho órfão é substituído.
  • Com gclid/wbraid/gbraid na URL, a visita é gravada no servidor (/lp), mesmo com bloqueador de anúncios; o beacon /api/track reaproveita o mesmo arrival_id/click_id (idempotente).
  • Conexões → card INS mostra a taxa de vendas ligadas ao clique (30 dias), o envio ao Google e o mini-painel Última venda (bv_get_clickbank_ops_status).

Projeção em transactions

transactions.external_ref da venda original é o recibo; rebill, reembolso e chargeback do mesmo recibo usam <recibo>:<transactionType>:<dedup_key>, para não colidirem no índice único (workspace_id, external_ref). A conferência por CSV e o QuickStats continuam casando por cb_conversions.receipt e pelo ledger. A conferência por CSV consome cada conversão uma única vez (recibo + tipo, preferindo a mesma data e o mesmo valor), então vários BILL do mesmo recibo batem corretamente. Rebill soma receita, mas não conta como conversão nova no Início/Financeiro.

Política de rebill no OCI

Rebill (BILL) reutiliza o recibo e a linha da venda original e geraria o mesmo order_id do PURCHASE no Google. Por isso:

  • REBILL → SKIP_REBILL_NOT_UPLOADED (não vai ao Google);
  • reembolso/chargeback de um rebill → SKIP_REBILL_REVERSAL_NOT_UPLOADED;
  • REVENUE → REBILL → RFND não retrata nem reapresenta o PURCHASE original: a ingestão liga o RFND ao rebill mais recente, e o PURCHASE da venda original continua como está no Google. Smart Bidding não vê o valor dos rebills (aceitável no 1º teste, de compra única e com Maximizar cliques).
flowchart LR
    CB[Webhook<br/>ClickBank] --> INS[Decrypt INS]
    INS --> VIT[bv_visits<br/>click_id]
    VIT --> LP[landing_page]
    LP --> CAM[campaign]
    CAM --> OFF[offer]
    VIT --> ORD[bv_transactions]

INS v2 — pipeline de ingestão atômico (atual)

O webhook roda um pipeline v2 atômico e idempotente dentro de uma única transação SQL (brandvelia.ingest_clickbank_event_v2, exposta service-only como bv_ingest_clickbank_event_v2):

  1. Raw claim imutável — o payload decriptado e allow-listed é armazenado uma única vez por dedup_key. Uma redelivery com conteúdo diferente sob a mesma chave é recusada (DEDUPE_PAYLOAD_MISMATCH) e contabilizada em conflict_count; a evidência raw original nunca é sobrescrita.
  2. Validação estrutural — allow-list rígida de chaves; PII de cliente nunca entra no store canônico.
  3. Atribuição — tracking codes resolvem click_id → bv_visits → landing → campanha/oferta dentro de um único workspace (CROSS_WORKSPACE_ATTRIBUTION caso contrário), com fallback para contract terms aprovados.
  4. Efeitos no ledger canônico — SALE/BILL/RFND/CGBK/INSF viram entradas assinadas e append-only em performance_ledger_entries, ligadas à cohort de aquisição, com links REBILL_OF / REVERSAL_OF e janelas de refund por entrada (event_at + contract.refund_window_days).
  5. Gate de integridade — quando line amounts e totalAccountAmount existem, devem reconciliar exatamente; caso contrário o evento falha com LINE_TOTAL_MISMATCH sem escrever economia.
  6. Projeção de compatibilidade — as telas legadas de finanças continuam funcionando via projeção sem PII em cb_conversions.

As bridges legadas (public.bv_cb_raw_events, public.bv_cb_conversions) foram revogadas; as telas agora leem a view allow-listed bv_cb_conversion_summaries e a RPC agregada bv_get_clickbank_raw_status.

Códigos de falha (estáveis)

Código Significado
DEDUPE_PAYLOAD_MISMATCH Redelivery conflitante sob dedup key existente.
LINE_TOTAL_MISMATCH Line items não somam totalAccountAmount.
WORKSPACE_NOT_RESOLVED / CONTRACT_NOT_RESOLVED Lacuna de atribuição/contrato — replayable.
INVALID_ACCOUNT_AMOUNT String monetária malformada.

Reconciliação QuickStats (adaptador v3)

O cron (/api/cron/quickstats-sync) reconcilia agregados diários do ClickBank contra o ledger canônico:

  • Cobertura completa obrigatória: uma janela bem-sucedida precisa conter exatamente uma linha por data de negócio. Dias faltantes falham o sync em vez de virar zero silencioso; o watermark nunca avança sobre lacunas.
  • Dinheiro como decimal-string: valores cruzam a fronteira TypeScript como strings decimais exatas e são parseados/agregados em SQL fixed-point (numeric(20,6)).
  • Cobertura de atribuição honesta: cobertura conta apenas cohorts com attribution_status = 'MATCHED' de CLICKBANK_INS; vendas não atribuídas degradam o eixo de atribuição para INVALID em vez de inflar cobertura.
  • Linhas de reconciliação caem em quickstats_reconciliation_daily + data_quality_snapshots, alimentando alertas determinísticos.

Referência vendored da API

A API REST do ClickBank está documentada como schemas JSON em clickbank-api/:

Arquivo Cobertura
products-v2.json Seller-only: CRUD de produtos/funis do próprio vendedor (/v2/sellers/{accountName}/products). Não é endpoint de afiliado — não existe GET /products/list para descobrir ofertas de terceiros.
orders-v2.json Fluxo de pedidos (fluxo completo, pedido normal, incompleto, campos faltantes).
analytics-v1.json Definições de analytics.

Não existe API de afiliado para listar o marketplace. products-v2.json exige accountName do vendedor e lista apenas produtos daquela conta. A descoberta de ofertas para afiliado é exclusivamente via marketplace web autenticado (accounts.clickbank.com). Ver auditoria 2026-08-23 em docs/features/radar.md.


Credenciais vendored

Credenciais ClickBank têm tratamento especial:

  • Podem ser vendored para links confiáveis conhecidos, mas nunca são commitadas no repositório.
  • Rotacionadas + trackeadas — cada rotação tem sua própria tag, tornando a rotação auditável.
  • A credencial pode ter tempo de vida limitado e pode ser destruída (memory-only) ou mantida como arquivo no pipeline de deploy.

Fontes de dados de marketplace (verificado 2026-08-20)

O Radar precisa de dados reais de marketplace (gravity, payouts, rebill). Fatos verificados:

  • Não existe feed XML público anônimo. As URLs historicamente citadas para o feed XML diário (marketplace.clickbank.net, marketplace-downloads.clickbank.net) não resolvem (NXDOMAIN). Não construir ingestão contra elas.
  • A UI web do marketplace (www.clickbank.com/marketplace → accounts.clickbank.com/marketplace.htm) exige sessão autenticada de afiliado.
  • A API REST (api.clickbank.com/rest/1.3/...) existe e retorna 401 sem a chave API Order Read (API Management) — gerá-la é uma ação do operador na conta ClickBank e é o caminho oficial para dados estruturados (QuickStats, Orders v2, Analytics v1.3 — schemas vendored em clickbank-api/).

Caminhos autorizados de ingestão para a Brandvelia:

  1. API REST ClickBank assim que a chave única API Order Read for provisionada em CLICKBANK_API_KEY_1 (CLICKBANK_API_KEY_2 opcional para uma 2ª conta; o par dev:clerk retorna 401).
  2. Snapshots fornecidos pelo operador (export CSV/JSON importado via SnapshotImportDialog / importMarketplaceSnapshotsAction) — validado com Zod, score calculado deterministicamente na importação.
  3. Feed URL configurado (env MARKETPLACE_FEED_URL) — se o ClickBank expuser um arquivo de feed autenticado para a conta, o cron diário o ingere; sem ele o cron registra uma execução skipped_no_source em vez de falhar.
  4. Browser scraper da sessão autenticada do próprio operador (source: browser_scraper) — ver abaixo. Aprovado pelo operador como o caminho central de inteligência de mercado enquanto nenhuma export/API oficial cobre a listagem.

Cada linha de snapshot mantém source, captured_at e (para imports) o operador como proveniência — dados de marketplace nunca são inventados.


Browser scraper (sessão autenticada, browser_scraper)

Captura semi-automatizada da listagem completa do marketplace logado (~1,2k ofertas, 50/página, sem botão de export). O operador faz login manualmente; a tooling apenas lê a listagem que a sessão já mostra.

Fluxo:

  1. Setup único: clique duplo em scripts/install-capture-agent.command — registra um mini app de protocol-handler (~/Applications/BrandveliaCapture.app, scheme brandvelia-capture://) e remove qualquer LaunchAgent residente legado. Nada fica residente e nada inicia no login: a bridge (servidor HTTP Node zero-dep, scripts/marketplace-bridge.mjs, 127.0.0.1:4173) acorda on demand e auto-sai após ~10 min ociosa (logs em ~/.brandvelia/bridge.log). pnpm capture:marketplace roda a mesma bridge em foreground. O app conversa com ela porque loopback é origem confiável (isento de mixed-content).
  2. Radar → "Capturar do Marketplace" → "Iniciar captura": com a bridge adormecida o botão navega para brandvelia-capture://start (o browser pede permissão uma vez); o handler app inicia a bridge e faz POST /start, que spawn scripts/marketplace-capture.mjs (playwright-core, channel: "chrome"). Com sessão válida salva no perfil persistente (~/.brandvelia/marketplace-chrome) roda headless/silencioso; só quando a sessão falta ou expira abre uma janela dedicada para o operador logar uma vez.
  3. O driver injeta scripts/marketplace-scraper.js via CDP (equivalente a console, contornando CSP) em modo embedded. O CSP connect-src do ClickBank bloqueia todo fetch página→loopback, então nada sai pela rede da página: progresso e itens ficam em window.__bvState e são coletados via CDP. O scraper primeiro descobre a API interna de listagem da SPA (hook fetch/XHR + uma navegação) e a repagina com cookies same-origin; se a descoberta falha, cai para DOM scraping (tabela pelos headers + paginação por hash + categorias da sidebar para o campo categoria). Ritmo human-like; kill switch window.__bvStop = true.
  4. O driver posta progresso (/progress) e o snapshot final (/snapshot) para a bridge; o dialog mostra progresso ao vivo e importa via importMarketplaceSnapshotsAction com source: "browser_scraper", registrando uma execução em brandvelia.marketplace_sync_runs. EPC/CVR da listagem são persistidos (migration 20260820150000).
  5. Fallback sem o driver (ex.: máquina sem Chrome/Playwright): o dialog oferece o source completo do scraper para colar no DevTools Console (F12) da aba logada; roda standalone e degrada para baixar o JSON + copiar para a clipboard, importável pelo SnapshotImportDialog.

Guardrails: recomenda-se uma captura por dia; upsert key (workspace_id, affiliate_network, identity_key, snapshot_date) torna re-imports idempotentes; cópias de auditoria vão para scripts/captures/ (gitignored). O scraper nunca posta credenciais — roda dentro da sessão existente do operador e só fala com loopback.

Hardening da captura (Fase 1, 2026-08-24)

  • Normalização compartilhada: a unidade pura packages/integrations/src/marketplace/normalize.ts é a fonte de verdade para moedas/percentuais (1,234.56 vs 1.234,56, símbolos), contagens inteiras, URLs seguras (apenas http/https; javascript:/data: viram ausência), domínio do vendor e hash canônico. O bloco equivalente embutido no scraper é pinado por teste de contrato — divergência quebra o build.
  • Ausência ≠ zero: campos não observados permanecem ausentes no envelope; apenas as colunas legadas NOT NULL recebem 0 documentado no banco, enquanto score_breakdown registra os reason codes *_UNKNOWN.
  • Retry/backoff: até 3 tentativas com backoff exponencial + jitter no driver, no POST ao bridge e na paginação DOM do scraper.
  • Completude declarada: o envelope carrega páginas esperadas/concluídas/ perdidas, falhas sanitizadas e status complete|partial. O bridge deriva o status quando ausente (nunca finge sucesso) e o server action recomputa o hash canônico antes de importar.
  • Provenância persistida (migration 20260824000000_brandvelia_capture_provenance.sql, additive-only, ainda não aplicada): vendor_domain, source_url, capture_status e capture_meta em brandvelia.marketplace_snapshots; sync runs aceitam status partial.