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:

  1. O handler chama event.preventDefault() e envia via Fetch.
  2. Não há <form> — só validação JS e POST.
  3. 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)

  1. Data Layer Variableiframe_form_id (nome do campo: iframe_form_id).
  2. Custom Event — nome do evento: iframe_form_success (ou ajax_form_success no fluxo AJAX).
  3. GA4 Event — nome do evento: generate_lead; parâmetro form_id = {{DLV - iframe_form_id}}.
  4. Marque generate_lead como key event / conversão no GA4 e importe no Ads se for o caso.

Fluxo resumido:

  1. Usuário envia o form no iFrame com sucesso.
  2. Filho chama postMessage com payload acordado.
  3. Listener do pai valida origin e faz dataLayer.push.
  4. 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 postMessage nem 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.