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:
- No Meta, você coloca a macro do ID do anúncio dentro do
xcod, no campo de parâmetros de URL do anúncio. - Você garante que esse
xcodchegue no link do checkout (pay.hotmart.com), inclusive quando tem uma página de vendas sua no meio. - Quando a compra muda de status, o webhook 2.0.0 devolve o valor dentro de
data.purchase.origin. - 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:
src: rastreia a origem da venda em link de afiliado ou em página de vendas alternativa. O exemplo da central égo.hotmart.com/XYZ?src=facebook.sck: usado pelo produtor pra rastrear campanha que manda direto pra página de pagamento. O exemplo oficial épay.hotmart.com/XYZ?checkoutMode=X&sck=fb.xcod: na documentação de desenvolvedor aparece como parâmetro UTM personalizado pelo usuário, definido no link de venda pra voltar na notificação. É o que a central de ajuda indica como alternativa justamente porque o UTM não chega no webhook.
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:
- Nome congela. A macro de nome usa o nome que o anúncio tinha quando foi publicado. Você renomeia "criativo 3" pra "criativo 3 VENCEDOR" e a URL continua mandando o nome antigo. Seu relatório e o Gerenciador param de falar a mesma língua.
- Nome tem lixo. Espaço, acento, barra e, quase sempre, underscore. Tudo isso vira problema num parâmetro que a Hotmart quer curto e sem
_. - ID é só dígito e não muda. É a chave estável que você vai usar no JOIN com o gasto.
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)
- Página no meio que engole a query string. Redirect de domínio, encurtador, construtor de página que monta o botão sem repassar parâmetro. O teste é burro e infalível: clique no anúncio, vá até o checkout e olhe a barra de endereço. Se o
xcodnão está lá, ele não vai estar no webhook. - Botão que nasce depois do script. Pop-up, botão carregado por JavaScript, checkout embutido. O
querySelectorAllroda no carregamento e não enxerga o que aparece depois. Rode o carimbo de novo quando o pop-up abrir, ou carimbe no clique. - Macro que chega como texto. Se o formato do anúncio não substitui a macro, ela chega literal na URL, e o seu código não pode tratar isso como ID. E Click to WhatsApp nem tem URL de destino: ali não existe
xcod, quem resolve é oreferraldo webhook do WhatsApp. Por isso a função valida que o ID é só dígito. - Enfiar
{{placement}}nosck. Os valores que o Meta devolve pra posicionamento vêm com underscore (tipoFacebook_Mobile_Feedouinstagram_stories, dependendo de quem documenta), e a Hotmart não aceita underscore nos códigos. Posicionamento vai no UTM, que é pro Analytics. - Troca de dispositivo e de navegador. Clicou no celular, comprou no notebook três dias depois: não tem
xcod, e não tem como ter. Mesmo no celular, Instagram e Facebook abrem o link no navegador interno do app; se a pessoa volta depois pelo Chrome, olocalStorageé outro. É perda estrutural. Meça, não esconda. - Link que você não montou. E-mail automático, link que o suporte mandou na mão, link de recuperação de ferramenta de terceiro. Se ele não carrega o seu
xcod, a venda chega sem origem. Ou você controla a montagem desses links também, ou aceita a lacuna. - Contar o evento errado. PIX ou boleto gerado não é venda. Reembolso e chargeback tiram venda. Se o seu ROAS soma tudo que tem
xcod, ele está otimista de propósito. - Distribuir as vendas sem origem "proporcionalmente". É tentador espalhar as sem origem entre os anúncios na mesma proporção das com origem. Se fizer, rotule como estimativa e deixe separado. Misturar estimativa com medido é como o seu número vira o número do pixel.
- Decidir criativo com pouca venda. Quando não usar ROAS por anúncio: se cada anúncio fecha duas ou três vendas no período, uma venda a mais ou a menos vira o ranking de cabeça pra baixo. Com volume baixo, olhe por conjunto ou alongue a janela. O dado está certo, só não carrega a decisão sozinho.
- Janela de tempo desalinhada. A consulta compara venda aprovada no mês com gasto no mês. Quem clicou no dia 30 e pagou o PIX no dia 2 cai no mês seguinte. Em período curto isso pesa; em mês fechado, quase some. Saiba qual você está olhando.
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.