Voltar pro blog json-p.org

PilarRastreamento honesto de vendas Publicado10 de novembro de 2026 AutorElton

Atribuição de venda Hotmart no Meta Ads pelo xcod

Atribuição de venda Hotmart no Meta Ads com xcod: a macro do anúncio na URL, o campo que volta no webhook e o SQL que liga cada venda ao anúncio.

Fechou a semana. O Gerenciador diz que a campanha gerou 31 compras, a Hotmart mostra 24 aprovadas e você tem doze anúncios rodando. A pergunta que decide o orçamento da semana que vem é uma só: qual desses doze vendeu? Aí você abre o payload do webhook da Hotmart pra procurar o utm_content que colou em todo link e ele não está lá. É nessa hora que a atribuição de venda Hotmart no Meta Ads que você achava que tinha vira palpite.

O pixel tem a versão dele. A Hotmart tem a dela (o painel de origem até mostra UTM, mas só lá dentro). E o seu sistema, que é quem dispara recuperação, manda mensagem no zap e soma o faturamento, recebe a venda sem saber de onde ela veio. Esses três números nunca vão bater certinho, e eu já destrinchei por que o ROAS do Meta não bate com a conta. Mas dá pra ter um número seu, venda por venda, que você consegue abrir e conferir.

O caminho existe e está na documentação da própria Hotmart. Só é pouco falado. Chama xcod.

A resposta curta: atribuição de venda Hotmart no Meta Ads passa pelo xcod

A central de ajuda da Hotmart diz com todas as letras que os parâmetros UTM estruturados não são repassados nos endpoints (webhook e API) e que a alternativa é o parâmetro xcod. Então o desenho fica assim:

  1. No Meta, você coloca a macro do ID do anúncio dentro do xcod, no campo de parâmetros de URL do anúncio.
  2. Você garante que esse xcod chegue no link do checkout (pay.hotmart.com), inclusive quando tem uma página de vendas sua no meio.
  3. Quando a compra muda de status, o webhook 2.0.0 devolve o valor dentro de data.purchase.origin.
  4. Você grava a venda com o ID do anúncio e cruza com o gasto por ID. Sai um ROAS por anúncio que você audita linha por linha.

Quatro peças, nenhuma mágica. O resto do post é cada peça com código e os lugares exatos onde ela quebra.

O caminho do parâmetro, ponta a ponta

Antes do código, o mapa. É isso que precisa acontecer em todo clique pra venda chegar com origem:

[anúncio no Meta]  clique
   URL final: https://suapagina.com.br/oferta?xcod=fb-120210000000000001&sck=metafrio
        ↓
[sua página de vendas]  um script copia xcod e sck pro botão de compra
        ↓
[checkout]  https://pay.hotmart.com/A00000000X?xcod=fb-120210000000000001&sck=metafrio
        ↓  compra aprovada
[webhook 2.0.0]  data.purchase.origin.xcod = "fb-120210000000000001"
        ↓
[seu banco]  vendas.chave = '120210000000000001'  JOIN  gasto_meta.ad_id

Os IDs aí são fictícios. Repare que o parâmetro atravessa três donos diferentes (Meta, você, Hotmart) e qualquer um deles pode derrubar ele no chão. Se o anúncio manda direto pro checkout, a etapa da página some e fica mais simples. Se tem WhatsApp no meio, aparece uma etapa a mais, que eu cubro mais pra frente.

src, sck e xcod: qual usar pra quê

A Hotmart tem três parâmetros próprios e a confusão entre eles é a origem de metade dos rastreamentos quebrados que eu já vi. Pela documentação:

O changelog do webhook 2.0 descreve um objeto origin que pode conter sck, src e xcod. Esse "pode" é literal: se você não mandou o parâmetro no link, ele não vem. Não trate ausência como erro do webhook.

A divisão de trabalho que funciona pra mim: xcod é a chave de máquina (carrega o ID, é o que o seu código lê), e sck é o rótulo pra humano, porque o painel de origem de vendas da Hotmart separa os dados em abas de UTM, SCK e SRC. Os utm_* continuam úteis, só que pro Hotmart Analytics e pro GA, não pro seu webhook.

Duas regras de formato que a central de ajuda traz pros códigos de UTM, SRC e SCK: até 30 caracteres, e underscore (_) proibido, porque é reservado pro sistema interno. Pro xcod eu não achei limite publicado. Na dúvida, desenho o xcod pra caber nas mesmas regras: curto e sem underscore.

Montando a URL do anúncio com as macros do Meta

O Meta substitui algumas macros na URL no momento do clique. As oito que valem aqui são {{campaign.id}}, {{campaign.name}}, {{adset.id}}, {{adset.name}}, {{ad.id}}, {{ad.name}}, {{placement}} e {{site_source_name}}. Você coloca no campo de parâmetros de URL, na etapa do anúncio, e o Meta anexa no fim do link de destino.

A tentação é jogar o nome da campanha no rastreamento, porque é legível. Não faça isso no xcod, por três motivos:

E por que só o {{ad.id}} e não os três IDs? Porque o anúncio pertence a um conjunto, que pertence a uma campanha. Com o ID do anúncio você recupera o resto pelo próprio relatório do Meta. E porque não cabe: no exemplo acima cada ID tem 18 dígitos (confira os da sua conta no Gerenciador), e três desses dão 54 caracteres antes de qualquer separador, quase o dobro de 30.

O que eu colo no campo de parâmetros de URL:

xcod=fb-{{ad.id}}&sck=metafrio&utm_source={{site_source_name}}&utm_medium=pago&utm_campaign={{campaign.name}}&utm_content={{ad.name}}

Leitura linha a linha: xcod leva o ID do anúncio com um prefixo fb-. O prefixo custa três caracteres e compra uma coisa valiosa: o mesmo campo passa a carregar outras origens (WhatsApp, e-mail, orgânico) sem ambiguidade na hora de ler. O sck é um texto fixo que você escolhe por grupo de campanhas (público frio, remarketing), sem underscore. Os UTMs ficam ali pro Analytics, sem nenhuma responsabilidade sobre o webhook.

Depois de publicar, clique num anúncio de verdade e olhe a URL que chegou. Tem que aparecer número no lugar da macro. Se aparecer o texto da macro, alguma coisa no formato do anúncio não está substituindo, e o seu código precisa estar preparado pra isso (mostro abaixo).

A página de vendas no meio: repassar o xcod pro botão

Se o anúncio cai numa página sua (WordPress, Elementor, HTML na VPS), o xcod chega na URL da página e morre ali. A própria Hotmart avisa que visita em página externa com parâmetros não vai sozinha pro sistema de vendas: o comprador precisa clicar num link oficial que já contenha os parâmetros. Ou seja, o botão "quero comprar" precisa carregar o xcod.

Um script genérico que resolve, colado antes do </body>:

<script>
(function () {
  var CHAVES = ['xcod', 'sck', 'src'];
  var daUrl = new URLSearchParams(location.search);
  var salvo = {};
  try { salvo = JSON.parse(localStorage.getItem('origem_hm') || '{}'); } catch (e) {}

  // último clique pago ganha: se chegou xcod novo na URL, ele substitui o guardado
  CHAVES.forEach(function (k) {
    var v = daUrl.get(k);
    if (v) salvo[k] = v;
  });
  try { localStorage.setItem('origem_hm', JSON.stringify(salvo)); } catch (e) {}

  // carimba todo link de checkout da página
  document.querySelectorAll('a[href*="pay.hotmart.com"]').forEach(function (a) {
    var url = new URL(a.href);
    CHAVES.forEach(function (k) {
      if (salvo[k] && !url.searchParams.has(k)) url.searchParams.set(k, salvo[k]);
    });
    a.href = url.toString();
  });
})();
</script>

Três decisões escondidas aí. Primeiro, usei a API URL pra montar o link, porque ela cuida da regra que a Hotmart insiste na central de ajuda (um ? só, o resto com &; dois pontos de interrogação corrompem a URL). Segundo, guardei no localStorage pra sobreviver a quem entra pela página de vendas, vai pra página de depoimentos e só depois clica em comprar. Terceiro, escolhi último clique: se a pessoa voltou por outro anúncio, o crédito vai pro mais recente. Se você prefere primeiro clique, troca a condição pra só gravar quando ainda não tem nada salvo. As duas são defensáveis. Misturar as duas é que não é.

WhatsApp no meio: o xcod que você monta na conversa

Quando o anúncio leva pro zap, a URL morre na fronteira do app. Expliquei o mecanismo e o contorno em por que o UTM se perde no WhatsApp: um redirect seu grava a origem com um id curto e carimba esse id no texto da mensagem. A peça que faltava naquele post é a volta pra Hotmart. Quando o bot (ou o atendente) manda o link de pagamento, o link já sai com o id dentro do xcod:

function linkCheckout(ref) {
  const u = new URL('https://pay.hotmart.com/A00000000X');
  if (ref) u.searchParams.set('xcod', 'wa-' + ref);  // ex: wa-A7K2Qv
  return u.toString();
}

Pra isso fechar, o link do anúncio que aponta pro seu redirect precisa levar o mesmo xcod=fb-{{ad.id}}, e o redirect grava esse ID junto com o ref. Aí o ref do WhatsApp aponta pro registro que tem o ID do anúncio. A venda volta com wa-A7K2Qv, você resolve o ref e chega no mesmo ad_id que uma venda direta chegaria. Dois caminhos, uma chave.

No webhook: ler data.purchase.origin e gravar sem perder

A parte de receber o webhook direito (validar o X-HOTMART-HOTTOK, responder 200 rápido, não processar o mesmo evento duas vezes) está em webhook Hotmart: todos os eventos. Aqui eu parto de um evento já validado e foco só na origem.

A função que extrai e valida o xcod:

// evento: corpo do webhook 2.0.0, hottok já conferido
function extrairOrigem(evento) {
  const origem = (evento.data && evento.data.purchase && evento.data.purchase.origin) || {};
  const bruto = (origem.xcod || '').trim();

  // formato combinado: "fb-<ad_id>" ou "wa-<ref>"
  const m = bruto.match(/^(fb|wa)-([A-Za-z0-9]+)$/);
  if (!m) return { canal: null, chave: null, bruto: bruto || null };

  const canal = m[1], chave = m[2];

  // ID de anúncio do Meta é só dígito; macro não substituída ou valor cortado cai aqui
  if (canal === 'fb' && !/^\d+$/.test(chave)) {
    return { canal: null, chave: null, bruto: bruto };
  }
  return { canal: canal, chave: chave, bruto: bruto };
}

O detalhe que importa: sempre devolve o bruto, mesmo quando não parseou. Valor que chegou torto é diagnóstico, não lixo. É ele que vai te mostrar a macro que não foi substituída ou a página que cortou o parâmetro pela metade.

A tabela e o upsert. Uma linha por transação, não por evento, porque a mesma compra gera vários eventos ao longo da vida (boleto ou PIX gerado, aprovada, reembolsada):

CREATE TABLE vendas (
  transacao   text PRIMARY KEY,   -- data.purchase.transaction
  status      text NOT NULL,      -- data.purchase.status
  valor       numeric(12,2),      -- data.purchase.price.value
  aprovada_em timestamptz,        -- data.purchase.approved_date (convertido)
  canal       text,               -- 'fb', 'wa' ou null
  chave       text,               -- ad_id ou ref do WhatsApp
  xcod_bruto  text,               -- o valor cru, sempre
  evento_em   bigint NOT NULL     -- creation_date do envelope
);

INSERT INTO vendas (transacao, status, valor, aprovada_em, canal, chave, xcod_bruto, evento_em)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (transacao) DO UPDATE SET
  -- reenvio de evento antigo não desfaz status novo
  status      = CASE WHEN EXCLUDED.evento_em > vendas.evento_em
                     THEN EXCLUDED.status ELSE vendas.status END,
  evento_em   = GREATEST(vendas.evento_em, EXCLUDED.evento_em),
  aprovada_em = COALESCE(vendas.aprovada_em, EXCLUDED.aprovada_em),
  valor       = COALESCE(EXCLUDED.valor, vendas.valor),
  -- nunca troca uma origem boa por vazio
  canal       = COALESCE(vendas.canal, EXCLUDED.canal),
  chave       = COALESCE(vendas.chave, EXCLUDED.chave),
  xcod_bruto  = COALESCE(vendas.xcod_bruto, EXCLUDED.xcod_bruto);

Os COALESCE na origem são defensivos de propósito. Eu não conto que todo evento da mesma transação traga o origin igualzinho, então a primeira origem válida que chegou fica. E a comparação de evento_em existe porque a Hotmart reenvia automaticamente post com erro (até cinco vezes, segundo a central de ajuda): um "aguardando pagamento" atrasado não pode passar por cima de um "aprovado".

Antes de confiar em qualquer caminho de campo, inclusive nos que eu escrevi aqui, abra a aba de histórico do webhook no painel da Hotmart. Ela mostra o payload de cada notificação enviada (a central fala em até 60 dias de histórico) e deixa você reenviar e mandar post de teste. Faça uma compra de teste com xcod no link e veja com os seus olhos onde o valor aparece. Documentação diz o que deveria vir. O payload diz o que vem.

Cruzando com o gasto: ROAS por anúncio que você audita

Do lado do Meta você precisa do gasto por dia e por anúncio, com o ID do anúncio como coluna. Pode ser um export do Gerenciador quebrado por anúncio ou a Insights API no nível de anúncio, puxando ad_id, ad_name e spend dia a dia. Cai numa tabela simples:

CREATE TABLE gasto_meta (
  dia     date,
  ad_id   text,
  ad_nome text,
  gasto   numeric(12,2),
  PRIMARY KEY (dia, ad_id)
);

E a consulta de reconciliação. Ela agrega venda e gasto separados antes de juntar, senão cada venda multiplica o gasto pelo número de dias e o ROAS sai inventado:

WITH venda_por_anuncio AS (
  SELECT v.transacao, v.valor,
         CASE v.canal WHEN 'fb' THEN v.chave
                      WHEN 'wa' THEN w.ad_id END AS ad_id
  FROM vendas v
  LEFT JOIN refs_whatsapp w ON v.canal = 'wa' AND w.ref = v.chave
  WHERE v.status IN ('APPROVED', 'COMPLETE')
    AND v.aprovada_em >= DATE '2026-10-01' AND v.aprovada_em < DATE '2026-11-01'
),
receita AS (
  SELECT ad_id, COUNT(*) AS vendas, SUM(valor) AS receita
  FROM venda_por_anuncio
  WHERE ad_id IS NOT NULL
  GROUP BY ad_id
),
gasto AS (
  SELECT ad_id, MAX(ad_nome) AS ad_nome, SUM(gasto) AS gasto
  FROM gasto_meta
  WHERE dia >= DATE '2026-10-01' AND dia < DATE '2026-11-01'
  GROUP BY ad_id
)
SELECT g.ad_id, g.ad_nome, g.gasto,
       COALESCE(r.vendas, 0)  AS vendas,
       COALESCE(r.receita, 0) AS receita,
       ROUND(COALESCE(r.receita, 0) / NULLIF(g.gasto, 0), 2) AS roas
FROM gasto g
LEFT JOIN receita r USING (ad_id)
ORDER BY g.gasto DESC;

A refs_whatsapp é a tabela do redirect do post do WhatsApp (ref, ad_id, quando). Só conta APPROVED e COMPLETE: PIX gerado não é venda, e reembolso sai da conta porque o status da transação muda pra REFUNDED e o upsert acompanha.

Agora a consulta que quase ninguém roda e que é a mais honesta de todas. Quanto das suas vendas aprovadas tem origem?

SELECT COUNT(*) AS aprovadas,
       COUNT(*) FILTER (WHERE canal IS NOT NULL) AS com_origem,
       ROUND(100.0 * COUNT(*) FILTER (WHERE canal IS NOT NULL) / NULLIF(COUNT(*), 0), 1) AS pct
FROM vendas
WHERE status IN ('APPROVED', 'COMPLETE')
  AND aprovada_em >= now() - interval '30 days';

-- e o que chegou mas não parseou
SELECT xcod_bruto, COUNT(*) FROM vendas
WHERE xcod_bruto IS NOT NULL AND canal IS NULL
GROUP BY 1 ORDER BY 2 DESC;

Esse percentual é a nota do seu rastreamento. Ele nunca vai ser 100%, e tudo bem. O problema é não saber qual é. Um ROAS por anúncio calculado sobre metade das vendas, sem você saber que é metade, engana mais do que o número do pixel. Esse cruzamento (venda por transação, gasto por ID de anúncio, cobertura medida à parte) é o que o utm, o tracker que eu mantenho, roda por baixo do capô. Mas nada do que está aqui depende dele: é um campo na URL, um script, uma tabela e duas consultas.

O que dá errado (e onde o xcod morre)

Fechando

A Hotmart não manda UTM pelo webhook, mas manda o xcod. Com o ID do anúncio dentro dele, um script que não deixa a página de vendas engolir o parâmetro e um upsert que não perde origem boa, você sai do palpite pra uma tabela onde cada venda aprovada aponta pra um anúncio, ou declara que não sabe. O número do Meta continua existindo e continua útil pro algoritmo. A diferença é que agora você tem um número seu pra colocar do lado, e sabe exatamente quanto dele é medido.

Fontes primárias consultadas em 10/11/2026: Central de Ajuda Hotmart, parâmetros de campanha (UTM, SRC, SCK e xcod); Central de Ajuda Hotmart, como identificar a origem das vendas; Central de Ajuda Hotmart, configurar webhook (histórico, reenvio, teste); Hotmart Developers, webhook de compra 2.0.0 e changelog (objeto origin); Meta Business Help Center, especificações dos parâmetros dinâmicos de URL.