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:
- O body bruto é decriptado/decodificado do INS (
decryptInsNotification). insTrackingCodes(order)resolve oclick_idaté uma linha debv_visits.- 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 GOOGLEACTIVE, campanha não arquivada) →landing_pages.campaign_id(não arquivada) → nome de campanha do INS (único, sem arquivadas) → pack GOOGLEREADYda pressel (prefere campanha já vista no Google). - Oferta: da pressel → da campanha resolvida → pelo
vendor, ignorando ofertas arquivadas; empate só é desfeito pela oferta com campanhaENABLEDno 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/gbraidna URL, a visita é gravada no servidor (/lp), mesmo com bloqueador de anúncios; o beacon/api/trackreaproveita o mesmoarrival_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):
- 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 emconflict_count; a evidência raw original nunca é sobrescrita. - Validação estrutural — allow-list rígida de chaves; PII de cliente nunca entra no store canônico.
- Atribuição — tracking codes resolvem
click_id → bv_visits → landing → campanha/ofertadentro de um único workspace (CROSS_WORKSPACE_ATTRIBUTIONcaso contrário), com fallback para contract terms aprovados. - 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 linksREBILL_OF/REVERSAL_OFe janelas de refund por entrada (event_at + contract.refund_window_days). - Gate de integridade — quando line amounts e
totalAccountAmountexistem, devem reconciliar exatamente; caso contrário o evento falha comLINE_TOTAL_MISMATCHsem escrever economia. - 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'deCLICKBANK_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.jsonexigeaccountNamedo 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 emdocs/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 emclickbank-api/).
Caminhos autorizados de ingestão para a Brandvelia:
- API REST ClickBank assim que a chave única API Order Read for provisionada
em
CLICKBANK_API_KEY_1(CLICKBANK_API_KEY_2opcional para uma 2ª conta; o pardev:clerkretorna 401). - Snapshots fornecidos pelo operador (export CSV/JSON importado via
SnapshotImportDialog/importMarketplaceSnapshotsAction) — validado com Zod, score calculado deterministicamente na importação. - 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çãoskipped_no_sourceem vez de falhar. - 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:
- Setup único: clique duplo em
scripts/install-capture-agent.command— registra um mini app de protocol-handler (~/Applications/BrandveliaCapture.app, schemebrandvelia-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:marketplaceroda a mesma bridge em foreground. O app conversa com ela porque loopback é origem confiável (isento de mixed-content). - 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 spawnscripts/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. - O driver injeta
scripts/marketplace-scraper.jsvia CDP (equivalente a console, contornando CSP) em modo embedded. O CSPconnect-srcdo ClickBank bloqueia todo fetch página→loopback, então nada sai pela rede da página: progresso e itens ficam emwindow.__bvStatee 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 switchwindow.__bvStop = true. - O driver posta progresso (
/progress) e o snapshot final (/snapshot) para a bridge; o dialog mostra progresso ao vivo e importa viaimportMarketplaceSnapshotsActioncomsource: "browser_scraper", registrando uma execução embrandvelia.marketplace_sync_runs. EPC/CVR da listagem são persistidos (migration20260820150000). - 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.56vs1.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_breakdownregistra 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_statusecapture_metaembrandvelia.marketplace_snapshots; sync runs aceitam statuspartial.