O problema: por que o evento de compra client-side não é suficiente
Implementar o evento purchase do GA4 via JavaScript no browser é tentador pela simplicidade, mas tem uma falha estrutural: você não controla o que acontece depois que o usuário clica em "finalizar compra".
O usuário pode:
- Fechar o browser antes de carregar a página de confirmação
- Ter um adblocker que bloqueia a tag do GTM
- Perder a conexão exatamente nesse momento
- Estar usando um browser com ITP agressivo (Safari) que bloqueia cookies e scripts de terceiros
Resultado: entre 10% e 35% dos pedidos não aparecem no GA4, dependendo do perfil de dispositivos e localização do seu tráfego.
A solução definitiva é enviar o evento de compra do servidor, usando o Order Hook da VTEX + Measurement Protocol do GA4.
O que é o Order Hook da VTEX
O Order Hook é um webhook da VTEX que dispara automaticamente quando o status de um pedido muda — incluindo quando um pedido é confirmado.
Quando um cliente finaliza uma compra no VTEX, o sistema:
- Cria o pedido no OMS (Order Management System)
- Processa o pagamento
- Confirma o pedido (status:
payment-approvedouorder-accepted) - Dispara o Order Hook → POST para uma URL configurada por você
O payload do webhook contém todos os dados do pedido: ID, itens, valor, cliente, forma de pagamento, frete, etc.
Arquitetura da solução
Cliente finaliza o pedido
↓
VTEX OMS confirma
↓
Order Hook dispara POST
para seu endpoint
↓
Seu servidor recebe o webhook
e valida a assinatura
↓
Envia evento "purchase" para GA4
via Measurement Protocol
↓
GA4 registra o pedido
com 100% de confiabilidade
Você precisará de:
- Um endpoint HTTPS para receber o webhook do VTEX
- Acesso às configurações de Order Hook no VTEX Admin
- Uma API Secret do GA4 (Measurement Protocol API Secret)
- O Measurement ID da sua propriedade GA4 (G-XXXXXXXX)
Passo 1: Criar o endpoint para receber o webhook
Crie um endpoint que aceita POST requests. Pode ser uma VTEX IO Service App, uma função no Vercel/Cloudflare Workers, ou qualquer backend Node.js.
Exemplo com VTEX IO Service (TypeScript)
// node/routes/orderHook.ts
import { IOContext, ParamsContext } from "@vtex/api";
interface OrderHookPayload {
Domain: string;
OrderId: string;
State: string;
LastState: string;
LastChange: string;
Origin: {
Account: string;
Key: string;
};
}
export async function orderHook(
ctx: IOContext & ParamsContext,
next: () => Promise<unknown>
) {
const body: OrderHookPayload = ctx.body;
// Só processa quando o pedido é confirmado
if (body.State !== "payment-approved" && body.State !== "order-accepted") {
ctx.status = 200;
return next();
}
try {
// Busca os detalhes completos do pedido
const order = await ctx.clients.oms.order(body.OrderId);
// Envia o evento para o GA4
await sendPurchaseEventToGA4(order);
ctx.status = 200;
} catch (err) {
// Loga o erro mas retorna 200 para o VTEX não fazer retry infinito
console.error("Erro ao enviar purchase event:", err);
ctx.status = 200;
}
return next();
}
Exemplo com Vercel Serverless Function (TypeScript)
// api/order-hook.ts
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).end();
}
const { OrderId, State } = req.body;
// Só processa pedidos confirmados
if (State !== "payment-approved" && State !== "order-accepted") {
return res.status(200).json({ ok: true });
}
try {
// Busca dados completos do pedido via API VTEX
const order = await fetchVTEXOrder(OrderId);
await sendGA4PurchaseEvent(order);
} catch (err) {
console.error(err);
}
// Sempre retorna 200 para o VTEX não fazer retry
return res.status(200).json({ ok: true });
}
Passo 2: Buscar os dados do pedido via API VTEX
O payload do Order Hook contém apenas o orderId. Para enviar um evento de compra completo ao GA4, você precisa buscar os detalhes do pedido na API de pedidos da VTEX.
async function fetchVTEXOrder(orderId: string) {
const account = process.env.VTEX_ACCOUNT; // ex: "minha-loja"
const appKey = process.env.VTEX_APP_KEY;
const appToken = process.env.VTEX_APP_TOKEN;
const response = await fetch(
`https://${account}.vtexcommercestable.com.br/api/oms/pvt/orders/${orderId}`,
{
headers: {
"X-VTEX-API-AppKey": appKey!,
"X-VTEX-API-AppToken": appToken!,
Accept: "application/json",
},
}
);
if (!response.ok) {
throw new Error(`Falha ao buscar pedido ${orderId}: ${response.status}`);
}
return response.json();
}
Passo 3: Mapear o pedido VTEX para o evento GA4
A parte mais importante é mapear corretamente os campos do pedido VTEX para os parâmetros do evento purchase do GA4.
interface VTEXOrder {
orderId: string;
value: number; // valor total em centavos
totals: Array<{
id: string;
name: string;
value: number; // em centavos
}>;
items: Array<{
id: string;
name: string;
price: number; // em centavos
sellingPrice: number; // em centavos
quantity: number;
productCategories: { [key: string]: string };
additionalInfo: {
brandName: string;
};
}>;
clientProfileData: {
id: string;
email: string;
};
paymentData: {
transactions: Array<{
payments: Array<{
paymentSystemName: string;
}>;
}>;
};
}
function mapVTEXOrderToGA4Event(order: VTEXOrder) {
// VTEX armazena valores em centavos — divide por 100
const toDecimal = (cents: number) => cents / 100;
// Separa os totais
const shippingTotal = order.totals.find((t) => t.id === "Shipping")?.value ?? 0;
const taxTotal = order.totals.find((t) => t.id === "Tax")?.value ?? 0;
const discountsTotal = Math.abs(
order.totals.find((t) => t.id === "Discounts")?.value ?? 0
);
// Valor dos itens (sem frete e sem taxa)
const itemsValue = toDecimal(order.value - shippingTotal - taxTotal);
// Mapeia os itens
const items = order.items.map((item, index) => ({
item_id: item.id,
item_name: item.name,
item_brand: item.additionalInfo?.brandName ?? "",
item_category: Object.values(item.productCategories ?? {})[0] ?? "",
price: toDecimal(item.sellingPrice),
quantity: item.quantity,
index,
}));
// Forma de pagamento (primeiro pagamento da primeira transação)
const paymentType =
order.paymentData?.transactions?.[0]?.payments?.[0]?.paymentSystemName ?? "";
return {
transaction_id: order.orderId,
value: itemsValue,
currency: "BRL",
shipping: toDecimal(shippingTotal),
tax: toDecimal(taxTotal),
coupon: "", // adicione se quiser rastrear cupons
payment_type: paymentType,
items,
};
}
Passo 4: Enviar o evento via Measurement Protocol
Com o evento mapeado, envie para o GA4 via Measurement Protocol.
async function sendGA4PurchaseEvent(order: VTEXOrder): Promise<void> {
const measurementId = process.env.GA4_MEASUREMENT_ID; // G-XXXXXXXX
const apiSecret = process.env.GA4_API_SECRET; // obtido no GA4 Admin
const purchaseEvent = mapVTEXOrderToGA4Event(order);
const payload = {
// client_id é obrigatório — use o ID do cliente VTEX ou um UUID fixo por usuário
// O ideal é recuperar o _ga cookie do browser, mas para server-side use o userId
client_id: order.clientProfileData.id,
// user_id opcional mas recomendado para cross-device
user_id: order.clientProfileData.id,
events: [
{
name: "purchase",
params: {
...purchaseEvent,
// Envia um session_id se disponível para associar ao session correto
// session_id: "...",
},
},
],
};
const url = new URL("https://www.google-analytics.com/mp/collect");
url.searchParams.set("measurement_id", measurementId!);
url.searchParams.set("api_secret", apiSecret!);
const response = await fetch(url.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
// Measurement Protocol retorna 204 No Content em sucesso
if (response.status !== 204) {
const body = await response.text();
throw new Error(`GA4 MP retornou ${response.status}: ${body}`);
}
console.log(`✅ Purchase event enviado para GA4: ${order.orderId}`);
}
Passo 5: Configurar o Order Hook no VTEX Admin
Com o endpoint pronto, configure o Order Hook no painel VTEX.
Via VTEX Admin (UI)
- Acesse VTEX Admin → Configurações da Loja → Gerenciamento de Pedidos
- Role até Configurações de Integração → Order Hook
- Adicione a URL do seu endpoint
- Selecione os eventos que devem disparar o hook (recomendado:
payment-approved)
Via API REST
curl -X POST \
"https://ACCOUNT.vtexcommercestable.com.br/api/orders/hook/config" \
-H "X-VTEX-API-AppKey: SEU_APP_KEY" \
-H "X-VTEX-API-AppToken: SEU_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filter": {
"status": ["payment-approved"]
},
"hook": {
"headers": {
"key": "Authorization",
"value": "Bearer SEU_TOKEN_SECRETO"
},
"url": "https://seu-endpoint.com/api/order-hook"
}
}'
Segurança: configure um token secreto no header da request e valide-o no seu endpoint para garantir que apenas o VTEX pode acionar o webhook.
Passo 6: Obter o GA4 API Secret
Para usar o Measurement Protocol, você precisa de uma API Secret gerada no GA4.
- Acesse GA4 Admin → Streams de dados → seu stream
- Role até Segredos de API para o Measurement Protocol
- Clique em Criar e copie o valor gerado
- Adicione como variável de ambiente
GA4_API_SECRET
Prevenindo eventos duplicados
Um risco real: se o Order Hook for disparado mais de uma vez para o mesmo pedido (timeout, retry), você terá o purchase duplicado no GA4.
Solução 1: GA4 desduplicação nativa
O GA4 desduplicará automaticamente eventos purchase com o mesmo transaction_id dentro de uma janela de 24 horas. Isso é garantido pela spec do GA4.
Solução 2: Cache de transaction_ids processados
Para garantia extra, mantenha um cache dos IDs já processados:
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
async function shouldProcessOrder(orderId: string): Promise<boolean> {
const key = `ga4_purchase_sent:${orderId}`;
const exists = await redis.exists(key);
if (exists) {
console.log(`Pedido ${orderId} já foi enviado ao GA4, ignorando.`);
return false;
}
// Marca como processado com TTL de 7 dias
await redis.setEx(key, 604800, "1");
return true;
}
Lidando com o client_id
O maior desafio do Measurement Protocol é o client_id — o identificador do usuário que o GA4 usa para associar o evento à sessão correta.
Em implementações client-side, o client_id vem do cookie _ga. No server-side, você precisa recuperá-lo.
Estratégia 1: Passar o _ga cookie via checkout
No checkout customizado, capture o valor do cookie _ga e salve no custom data do pedido VTEX:
// No seu script de checkout
const gaClientId = document.cookie
.split("; ")
.find((c) => c.startsWith("_ga="))
?.split("=")[1]
?.split(".").slice(2).join("."); // formato: GA1.1.XXXXXXXXXX.XXXXXXXXXX → XXXXXXXXXX.XXXXXXXXXX
if (gaClientId) {
// Salva no customData do pedido via orderForm API
vtexjs.checkout.sendAttachment("customData", {
customApps: [{
id: "ga-tracking",
fields: { clientId: gaClientId }
}]
});
}
No Order Hook, recupere o client_id do customData:
const gaClientId =
order.customData?.customApps?.find((a: { id: string }) => a.id === "ga-tracking")
?.fields?.clientId ?? order.clientProfileData.id;
Estratégia 2: Usar o email hasheado como user_id
Se não conseguir o client_id, use o email do cliente como user_id. Isso não vai associar ao session mas garante cross-device:
import { createHash } from "crypto";
const hashedEmail = createHash("sha256")
.update(order.clientProfileData.email.toLowerCase())
.digest("hex");
// No payload do Measurement Protocol
{
client_id: order.clientProfileData.id, // fallback: ID do cliente VTEX
user_id: hashedEmail,
events: [...]
}
Evitando conflito com o rastreamento client-side
Se você tem tanto o rastreamento client-side (via GTM) quanto o server-side (via Order Hook), precisa evitar que o evento purchase seja enviado em duplicidade.
Opção A: Desative o evento purchase no GTM
A mais simples: na tag GA4 Event do GTM que dispara o purchase, adicione uma condição de exceção que nunca seja verdadeira — efetivamente desativando-a — e confie 100% no server-side.
Opção B: Envie apenas do server-side, use client-side para os demais eventos
Esta é a arquitetura mais robusta:
| Evento | Client-side (GTM) | Server-side (Order Hook) |
|---|---|---|
view_item | ✅ | ❌ |
add_to_cart | ✅ | ❌ |
begin_checkout | ✅ | ❌ |
add_payment_info | ✅ | ❌ |
purchase | ❌ | ✅ |
Validação e troubleshooting
Testando o Measurement Protocol
Use o endpoint de validação do GA4 antes de enviar para produção:
curl -X POST \
"https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXX&api_secret=SEU_SECRET" \
-H "Content-Type: application/json" \
-d '{
"client_id": "123456.7890",
"events": [{
"name": "purchase",
"params": {
"transaction_id": "PEDIDO-TESTE-001",
"value": 199.90,
"currency": "BRL",
"items": [{
"item_id": "SKU-001",
"item_name": "Produto Teste",
"price": 199.90,
"quantity": 1
}]
}
}]
}'
O endpoint /debug/ retorna erros de validação sem enviar dados reais ao GA4.
Problemas comuns e soluções
| Problema | Causa provável | Solução |
|---|---|---|
| GA4 não mostra o evento | api_secret inválido | Gere um novo secret no GA4 Admin |
| Receita errada no GA4 | Valor em centavos não dividido por 100 | Confirme toDecimal() |
| Eventos duplicados | Order Hook disparando mais de uma vez | Implemente cache Redis |
client_id inválido | Formato incorreto | Use string não vazia |
| 204 mas evento não aparece | Delay do GA4 (~1-2 min) | Aguarde e verifique DebugView |
| Order Hook não dispara | URL incorreta ou timeout | Verifique logs no VTEX Admin |
Estrutura completa de produção
Aqui está a função completa pronta para produção:
// lib/ga4-order-hook.ts
const GA4_ENDPOINT = "https://www.google-analytics.com/mp/collect";
const GA4_DEBUG_ENDPOINT = "https://www.google-analytics.com/debug/mp/collect";
export async function processOrderHook(orderId: string): Promise<void> {
const [order] = await Promise.all([
fetchVTEXOrder(orderId),
]);
// Extrai o client_id do customData do pedido
const gaClientId =
order.customData?.customApps?.find(
(a: { id: string }) => a.id === "ga-tracking"
)?.fields?.clientId ?? order.clientProfileData.id;
const purchaseParams = mapVTEXOrderToGA4Event(order);
const payload = {
client_id: gaClientId,
user_id: order.clientProfileData.id,
timestamp_micros: Date.now() * 1000, // precisão em microssegundos
non_personalized_ads: false,
events: [
{
name: "purchase",
params: {
...purchaseParams,
engagement_time_msec: 100,
},
},
],
};
const url = new URL(
process.env.GA4_DEBUG === "true" ? GA4_DEBUG_ENDPOINT : GA4_ENDPOINT
);
url.searchParams.set("measurement_id", process.env.GA4_MEASUREMENT_ID!);
url.searchParams.set("api_secret", process.env.GA4_API_SECRET!);
const response = await fetch(url.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (process.env.GA4_DEBUG === "true") {
const debug = await response.json();
console.log("GA4 Debug:", JSON.stringify(debug, null, 2));
}
if (response.status !== 204 && response.status !== 200) {
throw new Error(`GA4 MP error: ${response.status}`);
}
}
Próximos passos
Com o evento de compra server-side implementado, você tem uma base sólida para:
- Comparar receita GA4 vs. VTEX Admin diariamente para monitorar a qualidade dos dados
- Configurar alertas automáticos quando a divergência ultrapassar 5%
- Usar os dados de conversão para treinar campanhas de Smart Bidding no Google Ads com dados 100% confiáveis
- Enviar o mesmo evento para o Meta Conversions API usando o mesmo Order Hook
- Exportar para BigQuery e cruzar com dados de CRM para análise de LTV
Para ajuda com a implementação completa, fale com um especialista GA4 ou veja o guia completo de rastreamento de e-commerce.