Como Taguear Formulários Complexos e iFrames no Google Tag Manager
Por que o Form Submission do GTM falha em AJAX/iFrame, e como capturar generate_lead com dataLayer, XHR/Fetch e postMessage.
Contexto
O lead entra no CRM. No GA4 e no Google Ads, a conversão não aparece — ou aparece como Direct no dia seguinte.
Quase sempre o formulário não é o HTML clássico com submit + thank-you page. É AJAX (Fetch/XHR), botão type="button", Typeform/CRM em iFrame, ou um SPA que atualiza a UI sem mudar a URL. O trigger Form Submission do GTM foi feito para o mundo antigo. No mundo novo, ele escuta um evento que a aplicação cancelou — ou nem existe.
Este guia é o mapa prático: mesma origem (AJAX) vs iFrame cross-origin, o que preferir, e como validar generate_lead de ponta a ponta.
Problema
Sinais de que o tagueamento de formulário está quebrado:
| Sintoma | Causa típica |
|---|---|
| Form Submission nunca dispara | preventDefault / stopPropagation no JS da aplicação |
| Clique no botão, zero evento | <button type="button"> ou <div> sem <form> / sem submit |
| Sucesso na tela, silêncio no GTM | Resposta AJAX sem reload; não há thank-you page |
| Element Visibility some no redesign | Classe/ID do “obrigado” mudou |
| Lead no Typeform/CRM, sessão nova no GA4 | Segundo GTM dentro do iFrame com Client ID isolado |
| Origem “referral” do próprio site | Sessão do iFrame sem herdar a sessão do pai |
A dor de mídia é direta: otimização de campanha em cima de conversão subnotificada. O fix não é “mais um pixel no botão” — é arquitetura de evento no momento em que o servidor (ou o iframe) confirma sucesso.
Por que o Form Submission nativo falha
O acionador nativo do GTM escuta submit no window e assume que o evento sobe a árvore do DOM. Em apps modernas:
- O handler chama
event.preventDefault()e envia via Fetch. - Não há
<form>— só validação JS ePOST. - A UI mostra “enviado” sem navegação.
Resultado: o GTM nunca vê o submit. Thank-you page por URL também some. Você precisa de um sinal explícito de sucesso de negócio — não de um acidente de DOM.
Mesma origem: o que usar
Quando o formulário roda no mesmo domínio do site (sem iFrame de terceiro), escolha pela capacidade de acesso ao código:
| Método | Precisa de eng? | Vantagem | Limite |
|---|---|---|---|
| Form Submission nativo | Não | Zero código | Quebra com AJAX / preventDefault |
| Element Visibility | Não | Rápido no GTM | Frágil a CSS/UI |
| Interceção XHR / Fetch | Só GTM (Custom HTML) | Detecta HTTP 200 real | Manutenção; filtrar endpoints |
dataLayer.push no sucesso |
Sim | Contrato estável | Exige PR de frontend |
Regra: se engenharia pode tocar no form, prefira dataLayer.push no callback de sucesso — mesmo contrato do Guia de Data Layer para Devs. Interceção de rede é ponte, não arquitetura definitiva.
Exemplo mínimo de push no sucesso (lado da aplicação):
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'generate_lead',
form_id: 'contato-home',
form_destination: '/api/leads'
});
Ponte: interceção Fetch (Custom HTML no GTM)
Quando não há acesso ao repo, um Custom HTML no GTM (All Pages, ou só nas URLs do formulário) pode envolver window.fetch e empurrar evento só em respostas de sucesso do endpoint certo:
(function () {
if (!window.fetch || window.__dqbFetchHooked) return;
window.__dqbFetchHooked = true;
var originalFetch = window.fetch;
var leadEndpoints = ['/api/leads', '/wp-json/contact-form'];
window.fetch = function () {
var args = arguments;
var url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
return originalFetch.apply(this, args).then(function (response) {
var matched = leadEndpoints.some(function (path) {
return url.indexOf(path) !== -1;
});
if (matched && response.ok) {
var clone = response.clone();
clone.json().then(function (data) {
if (data && (data.success === true || data.status === 'ok' || data.status === 'submitted')) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'ajax_form_success',
form_endpoint: url
});
}
}).catch(function () {});
}
return response;
});
};
})();
No GTM: Custom Event ajax_form_success → tag GA4 Event generate_lead (param form_id / endpoint via Data Layer Variable). Ajuste a lista leadEndpoints e o critério de data.success ao JSON real do seu backend — senão você marca “conversão” em 200 de health-check.
O mesmo padrão existe para XMLHttpRequest.prototype.send em stacks ainda baseadas em XHR. O princípio é idêntico: sucesso de rede + endpoint de lead, não clique em botão.
iFrames: Same-Origin Policy
Formulários embutidos (Typeform, HubSpot, Calendly, booking, gateway) vivem em outra origem. Protocolo + domínio + porta diferentes = o GTM do pai não lê o DOM do filho. Clique, submit e Element Visibility dentro do iFrame são invisíveis para o container da página.
Por que não instalar um segundo GTM no iFrame
Solução intuitiva, efeitos colaterais caros:
- Client ID fragmentado — o GA4 no filho cria identidade própria (cookies de terceiros / particionamento).
- Sessão nova — atribuição vira Direct ou referral do domínio pai.
- Duplicação — page_view e tags globais disparam duas vezes se ambos os containers forem “completos”.
Sem protocolo entre janelas, você troca “zero conversão” por “conversão com atribuição podre”.
Arquitetura recomendada: postMessage child → parent
Um container GTM só no pai. O filho avisa o sucesso; o pai registra a conversão com a sessão que já existe.
No iFrame (quando você controla o HTML embutido ou o fornecedor permite script / webhook de “on submit”):
function notifyParentOnSuccess(formData) {
window.parent.postMessage(
{
type: 'iframe_form_submission',
status: 'success',
form_id: formData.id || 'lead_form'
},
'https://www.seudominio.com.br' // origem exata do pai
);
}
No pai — Custom HTML no GTM (All Pages das páginas que embutem o iframe):
(function () {
if (window.__iframeMessageListenerInstalled) return;
window.__iframeMessageListenerInstalled = true;
var allowedOrigins = [
'https://formulario.seufornecedor.com',
'https://embed.typeform.com'
];
window.addEventListener('message', function (event) {
if (allowedOrigins.indexOf(event.origin) === -1) return;
var data = event.data;
if (typeof data === 'string') {
try { data = JSON.parse(data); } catch (e) { return; }
}
if (data && data.type === 'iframe_form_submission' && data.status === 'success') {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'iframe_form_success',
iframe_origin: event.origin,
iframe_form_id: data.form_id || 'unknown'
});
}
});
})();
Obrigatório: allowlist de event.origin. Sem isso, qualquer script pode forjar message e poluir conversões.
Quando o fornecedor não permite script no filho, as saídas realistas são: callback oficial da plataforma (se existir), thank-you URL no pai após redirect, ou Measurement Protocol no backend que processa o lead — com client_id enviado no formulário. Não invente scraper do DOM do iframe: o browser bloqueia.
Setup no GTM (página pai)
- Data Layer Variable —
iframe_form_id(nome do campo:iframe_form_id). - Custom Event — nome do evento:
iframe_form_success(ouajax_form_successno fluxo AJAX). - GA4 Event — nome do evento:
generate_lead; parâmetroform_id={{DLV - iframe_form_id}}. - Marque
generate_leadcomo key event / conversão no GA4 e importe no Ads se for o caso.
Fluxo resumido:
- Usuário envia o form no iFrame com sucesso.
- Filho chama
postMessagecom payload acordado. - Listener do pai valida
origine fazdataLayer.push. - Custom Event dispara a tag GA4 com a sessão do pai.
Para formulários longos dentro do iFrame (várias “páginas” internas) que exigem tracking no filho, aí entra sincronização de client_id / session_id via handshake bidirecional — escopo maior; trate como projeto separado. Neste meio de funil, o alvo é uma conversão de lead no pai.
Validação
| Etapa | O que conferir |
|---|---|
| Console (pai) | addEventListener('message', console.log) — payload e origin chegam |
| Tag Assistant | Evento iframe_form_success / ajax_form_success na prévia |
| GA4 DebugView | generate_lead com form_id; mesma sessão da navegação no pai |
| Ads / CRM | Volume de conversões vs leads reais na janela de teste |
Se o message aparece no Console mas não no Tag Assistant, o listener não está instalado (tag não disparou / condição de página). Se o Custom Event aparece mas o GA4 não, consentimento ou tag mal ligada ao trigger.
Segurança e consentimento
- Valide sempre
event.origin. - Não coloque PII no
postMessagenem no dataLayer (e-mail em claro, telefone). - Tags disparadas por esses eventos devem respeitar Consent Mode: sem consentimento de analytics/ads, não “consertar” a conversão furando o banner.
- Processar no contexto first-party do pai também evita a armadilha de cookies de terceiros no iFrame — ver O fim dos cookies de terceiros.
Checklist de QA
| # | Verificação | Feito quando... |
|---|---|---|
| 1 | Método certo | AJAX → push ou Fetch hook; iFrame → postMessage (não Form Submission cego) |
| 2 | Endpoint filtrado | Interceção de rede só nos paths de lead |
| 3 | Allowlist | event.origin restrito aos domínios do fornecedor |
| 4 | Evento GA4 | generate_lead + form_id estável |
| 5 | Sessão | DebugView: mesma jornada do pai (sem referral do próprio domínio) |
| 6 | Consent | Tag não dispara antes do update de consentimento |
Próximo passo
Se o formulário que paga a campanha é AJAX ou vive em iFrame e o GTM ainda depende de Form Submission / Element Visibility, o gap não é “mais um trigger” — é mapa de eventos: quais forms existem, quem controla o código do filho, qual payload de sucesso e quem é dono do generate_lead.
Peça um diagnóstico de tracking focando nos formulários que geram lead: listamos origem (same-origin vs iframe), o contrato mínimo de dataLayer/postMessage e o que sobra para Custom HTML. Isso desbloqueia Ads e GA4 sem esperar o próximo redesign quebrar o seletor CSS de novo.