FreeGoogle Tag Manager recipes

Track ActiveCampaign Form Submissions in Google Tag Manager with this Free GTM Recipe

A free Google Tag Manager recipe that detects confirmed ActiveCampaign form submissions and gives you a trigger for sending conversions to Google Ads, Google Analytics and beyond.

Aaron Beashel
Aaron BeashelUpdated September 20267 min read
The ActiveCampaign recipe tag inside Google Tag Manager

Lots of marketers rely on Google Tag Manager to report a conversion to Google Ads, Google Analytics, etc each time someone fills in an ActiveCampaign form on their website.

The catch is that ActiveCampaign forms are tricky to track. When someone clicks submit, ActiveCampaign cancels the browser's normal submit process and sends the data itself, which means Google Tag Manager's built-in Form Submission trigger doesn't work. ActiveCampaign doesn't send a proper success signal back to the browser either, so you need to write custom code to monitor for success messages becoming visible on the page, or for ActiveCampaign to redirect the user to the thank you page.

Unless you're a developer or a seasoned Google Tag Manager user, that's a big ask.

Luckily, there's an easier way. Just import this ActiveCampaign Conversion Tracking Recipe into your Google Tag Manager account and you'll have every piece you need for basic conversion tracking.

What Is a Google Tag Manager Recipe and What's in It?

A recipe is a ready-made bundle of Google Tag Manager assets (tags, triggers, variables, etc) that you can import into your account rather than writing the code and doing the configuration for each of them yourself.

This recipe detects successful ActiveCampaign form submissions, passes a 'Form Submitted' event to Google Tag Manager, and gives you a Trigger for sending conversions to Google Analytics, Google Ads and more. It's made up of three parts:

Custom HTML Tag

Waits for ActiveCampaign to confirm a successful form submission (by showing the form's thank you message, or by redirecting the visitor to your thank you page) and then pushes an 'activecampaign_form_submitted' event to the dataLayer. It fires on All Pages, once per event, and this is the code inside it:

The Tag's CodeJavaScript · 725 lines
(function () {
  'use strict';
  // Install once, even if GTM injects this tag more than once on the page.
  if (window.__converlyRecipeActivecampaign) return;
  window.__converlyRecipeActivecampaign = true;
  window.dataLayer = window.dataLayer || [];

  var EVENT_NAME = 'activecampaign_form_submitted';

  // Two submit events on the same form this close together are one attempt
  // (a double click, or a re-dispatch), not two.
  var RAPID_RESUBMIT_MS = 500;

  // An attempt still undecided after this long is dropped, with anything it
  // holds. ActiveCampaign's round trip was measured at about 2.5 seconds.
  var WATCH_MS = 60000;

  // Private marker set on each ActiveCampaign reply script this tag has seen.
  // Named per snippet, so the recipe and the Method 2 snippet never read each
  // other's marks.
  var CLAIM_KEY = '__converlyRecipeActivecampaignSeen';

  // Only used by browsers WITHOUT the Navigation API (older Safari). There, a
  // page navigation counts as ActiveCampaign's redirect only if it starts
  // within this long of ActiveCampaign's response. Same window as Converly's
  // production module.
  var NAV_TRUST_MS = 4000;

  // Chrome, Edge, Firefox and Safari 26.2+ have the Navigation API, and in all
  // of them its `navigate` event fires WHILE the script that started the
  // navigation is still running. That lets this tag prove it was
  // ActiveCampaign's own response that sent the visitor to the thank-you page,
  // rather than inferring it from timing.
  var HAS_NAV_API = false;
  try {
    HAS_NAV_API = !!(window.navigation && typeof window.navigation.addEventListener === 'function');
  } catch (e) {
    HAS_NAV_API = false;
  }

  function now() { return new Date().getTime(); }

  // ============================================================
  // Which forms are ActiveCampaign forms, and which form is it?
  // ============================================================
  // ActiveCampaign renders a real <form> into the page for every style
  // (inline, modal, floating box, floating bar) and both embed options (the
  // Simple Embed script and the Full Embed paste). The stable identity across
  // all of them is the `_form _form_<id>` class pair plus a hidden input
  // `f=<id>` carrying the same number. The element id and the hidden `u` do
  // NOT carry the form id (they are a per-form hash on a Simple Embed and the
  // number on a Full Embed), so neither is used.

  function parseUrlParts(str) {
    if (!str) return null;
    var s = String(str);
    try {
      if (typeof URL === 'function') {
        var u = new URL(s);
        return {
          scheme: (u.protocol || '').replace(/:$/, '').toLowerCase(),
          host: (u.hostname || '').toLowerCase(),
          path: u.pathname || '/'
        };
      }
    } catch (e) {}
    var m = /^([a-z][a-z0-9+.\-]*):\/\/([^\/?#]*)([^?#]*)/i.exec(s);
    if (!m) return null;
    var authority = m[2] || '';
    var at = authority.lastIndexOf('@');
    if (at !== -1) authority = authority.slice(at + 1);
    return {
      scheme: m[1].toLowerCase(),
      host: authority.replace(/:\d+$/, '').toLowerCase(),
      path: m[3] || '/'
    };
  }

  // ActiveCampaign's submission endpoint: https://<account>.activehosted.com/proc.php
  // Matched structurally, never by substring, so a lookalike URL such as
  // https://example.com/x?next=activehosted.com/proc.php can never pass.
  function isAcProcUrl(parts) {
    return !!parts && parts.scheme === 'https' && /\.activehosted\.com$/.test(parts.host) &&
      parts.path === '/proc.php';
  }

  // Reads ONE control key off a submission URL. That URL also carries every
  // value the visitor typed, so it is never split, decoded, stored or logged.
  function readQueryControl(src, key) {
    if (key !== 'f' && key !== 'act' && key !== 'jsonp') return null;
    var m = new RegExp('[?&]' + key + '=([^&#]*)').exec(String(src));
    return m ? m[1] : null;
  }

  function classOf(el) {
    var cls = el && typeof el.className === 'string' ? el.className : '';
    if (!cls && el && el.getAttribute) {
      try { cls = el.getAttribute('class') || ''; } catch (e) { cls = ''; }
    }
    return cls;
  }

  function classFormId(formEl) {
    var cls = classOf(formEl);
    if (!/(^|\s)_form(\s|$)/.test(cls)) return null;
    var m = /(^|\s)_form_(\d+)(\s|$)/.exec(cls);
    return m ? m[2] : null;
  }

  function hiddenControlValue(formEl, name) {
    try {
      var el = formEl.querySelector('input[type="hidden"][name="' + name + '"]');
      if (!el) return null;
      return String(el.value == null ? '' : el.value);
    } catch (e) {
      return null;
    }
  }

  function numericHiddenF(formEl) {
    var f = hiddenControlValue(formEl, 'f');
    return (f !== null && /^\d+$/.test(f)) ? f : null;
  }

  // Returns { formId, host } for an ActiveCampaign sign-up form, or null for
  // anything else. The hidden `act` must be `sub` (a subscription), so
  // ActiveCampaign's unsubscribe and preference forms are never watched.
  // `host` is the account's exact activehosted.com host, read from the form's
  // own action. It is null when the action is not ActiveCampaign's endpoint,
  // and then only the thank-you signal can fire for that form.
  function describeForm(formEl) {
    if (!formEl || (formEl.tagName || '').toLowerCase() !== 'form') return null;
    var f = numericHiddenF(formEl);
    if (!f) return null;
    if (hiddenControlValue(formEl, 'act') !== 'sub') return null;
    var parts = null;
    try { parts = parseUrlParts(String(formEl.action || '').replace(/[?#][\s\S]*$/, '')); } catch (e) { parts = null; }
    var host = isAcProcUrl(parts) ? parts.host : null;
    if (classFormId(formEl) === f || host) return { formId: f, host: host };
    return null;
  }

  // ============================================================
  // Visibility and error helpers
  // ============================================================

  function isNodeHidden(el) {
    try {
      if (el.hidden) return true;
      if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return true;
      var view = el.ownerDocument && el.ownerDocument.defaultView;
      if (view && view.getComputedStyle) {
        var cs = view.getComputedStyle(el);
        if (cs && (cs.display === 'none' || cs.visibility === 'hidden')) return true;
      }
    } catch (e) {}
    return false;
  }

  // Hidden itself, or inside something hidden, up to but NOT including the
  // form. Whether the form itself is on screen (a modal the visitor closed
  // while the request was in flight, say) says nothing about the verdict.
  function isConcealed(el, form) {
    var node = el;
    var depth = 0;
    while (node && node.nodeType === 1 && node !== form && depth < 12) {
      if (isNodeHidden(node)) return true;
      node = node.parentNode;
      depth++;
    }
    return false;
  }

  function hasContent(el) {
    try {
      if (String(el.textContent || '').replace(/^\s+|\s+$/g, '') !== '') return true;
      if (el.children && el.children.length > 0) return true;
    } catch (e) {}
    return false;
  }

  // ActiveCampaign's two rejection displays. In-browser validation marks the
  // field `._has_error` and adds an `._error-inner` tooltip, and it does this
  // again as the visitor types, even after the data has been sent. A server
  // or network rejection inserts an `._error-inner._form_error` box above the
  // submit button, and ONLY ActiveCampaign's `_show_error` ever draws that.
  var ERROR_SELECTOR = '._has_error, ._error-inner';

  function collectErrorNodes(w) {
    var found = [];
    try {
      var nodes = w.form.querySelectorAll(ERROR_SELECTOR);
      for (var i = 0; i < nodes.length; i++) {
        if (!isConcealed(nodes[i], w.form)) found.push(nodes[i]);
      }
    } catch (e) {}
    return found;
  }

  function serverErrorNodes(w) {
    try { return w.form.querySelectorAll('._form_error'); } catch (e) { return []; }
  }

  function snapshotErrors(w) {
    var nodes = collectErrorNodes(w);
    w.errorsAtArm = [];
    for (var i = 0; i < nodes.length; i++) {
      w.errorsAtArm.push({ node: nodes[i], text: String(nodes[i].textContent || '') });
    }
  }

  // A `._form_error` box that was not in the form when this attempt started
  // is the server's (or the network's) verdict on it. Visible or not: a site
  // can hide ActiveCampaign's error box with its own CSS, and the rejection
  // is just as real.
  function hasNewServerError(w) {
    var nodes = serverErrorNodes(w);
    for (var i = 0; i < nodes.length; i++) {
      var known = false;
      for (var j = 0; j < w.serverErrorsAtArm.length; j++) {
        if (w.serverErrorsAtArm[j] === nodes[i]) { known = true; break; }
      }
      if (!known) return true;
    }
    return false;
  }

  // An error that was already showing when this attempt started is not a
  // verdict on it. Once it goes away it is forgotten, so if it comes back it
  // counts as new (ActiveCampaign rebuilds its tooltips on every attempt).
  // Returns the visible error nodes that are new since the attempt started.
  function newErrorNodes(w) {
    var i;
    var fresh = [];
    for (i = w.errorsAtArm.length - 1; i >= 0; i--) {
      var old = w.errorsAtArm[i];
      var gone = true;
      try {
        gone = old.node.isConnected !== true || isConcealed(old.node, w.form) ||
          String(old.node.textContent || '') !== old.text;
      } catch (e) { gone = true; }
      if (gone) w.errorsAtArm.splice(i, 1);
    }
    var current = collectErrorNodes(w);
    for (i = 0; i < current.length; i++) {
      var stale = false;
      for (var j = 0; j < w.errorsAtArm.length; j++) {
        if (w.errorsAtArm[j].node === current[i] &&
            w.errorsAtArm[j].text === String(current[i].textContent || '')) {
          stale = true;
          break;
        }
      }
      if (!stale) fresh.push(current[i]);
    }
    return fresh;
  }

  // ============================================================
  // Attempt tracking: one watcher per form, per submit
  // ============================================================

  var watchers = [];       // attempts still waiting for a verdict
  var armCount = 0;
  // Counts replies and navigation starts in the order they happen. The clock
  // only has whole milliseconds, so two events in the same millisecond cannot
  // be ordered by time. This says which really came first.
  var eventCount = 0;
  var recentlyFired = [];  // forms that fired within RAPID_RESUBMIT_MS
  var beforeUnloadOn = false;

  function findWatcher(formEl) {
    for (var i = 0; i < watchers.length; i++) {
      if (watchers[i].form === formEl) return watchers[i];
    }
    return null;
  }

  function firedRecently(formEl) {
    var t = now();
    for (var i = recentlyFired.length - 1; i >= 0; i--) {
      if (t - recentlyFired[i].at >= RAPID_RESUBMIT_MS) { recentlyFired.splice(i, 1); continue; }
      if (recentlyFired[i].form === formEl) return true;
    }
    return false;
  }

  function safeCurrentScript() {
    try {
      var cs = document.currentScript;
      if (cs && cs.nodeType === 1 && (cs.tagName || '').toLowerCase() === 'script') return cs;
    } catch (e) {}
    return null;
  }

  // ---- Matching ActiveCampaign's response to the attempt that sent it ----
  //
  // ActiveCampaign submits by adding a script tag whose address is
  // https://<account>.activehosted.com/proc.php?...&f=<id>&act=sub&...&jsonp=true
  // Whatever that script does when it runs IS the server's verdict. It shows
  // the thank-you message, sends the visitor to a thank-you URL, or shows an
  // error.
  //
  // ActiveCampaign adds that script synchronously, inside the very submit
  // event it answers, so it belongs to the attempt that was armed in that same
  // event. A script is therefore matched only to an attempt armed in the
  // current task (the newest one for that account and form, on a form still in
  // the page). Every ActiveCampaign script this tag sees is marked the moment
  // it is seen, matched or not, so no later attempt can ever be credited with
  // an earlier reply. Once a reply has run, the mark is reduced to a plain
  // string and every reference to the script is dropped, because its address
  // carries everything the visitor typed.

  function claimOf(node) {
    try {
      var v = node[CLAIM_KEY];
      return (v && typeof v === 'object') ? v : null;
    } catch (e) {
      return null;
    }
  }

  function markNode(node, value) {
    try { node[CLAIM_KEY] = value; } catch (e) {}
  }

  function isAcSubmitScript(node) {
    if (!node || node.nodeType !== 1 || (node.tagName || '').toLowerCase() !== 'script') return null;
    var type = '';
    try { type = String((node.getAttribute && node.getAttribute('type')) || '').toLowerCase(); } catch (e) { return null; }
    if (type && type !== 'text/javascript' && type !== 'application/javascript') return null;
    var src = '';
    try { src = String(node.src || ''); } catch (e) { return null; }
    // Only the address up to the "?" is parsed. The rest holds the visitor's
    // values and is only ever searched for the three control keys.
    var parts = parseUrlParts(src.replace(/[?#][\s\S]*$/, ''));
    if (!isAcProcUrl(parts)) return null;
    if (readQueryControl(src, 'act') !== 'sub' || readQueryControl(src, 'jsonp') !== 'true') return null;
    var f = readQueryControl(src, 'f');
    src = null;
    if (!f || !/^\d+$/.test(f)) return null;
    return { host: parts.host, f: f };
  }

  function tryClaim(node) {
    try {
      if (!node || node[CLAIM_KEY] !== undefined) return claimOf(node);
    } catch (e) {
      return null;
    }
    var info = isAcSubmitScript(node);
    if (!info) return null;
    var owner = null;
    for (var i = 0; i < watchers.length; i++) {
      var w = watchers[i];
      if (!w.claimOpen || w.sent || w.host !== info.host || w.formId !== info.f) continue;
      try { if (w.form.isConnected !== true) continue; } catch (e) { continue; }
      if (!owner || w.seq > owner.seq) owner = w;
    }
    if (!owner) {
      markNode(node, 'unowned');
      return null;
    }
    var claim = { node: node, watcher: owner, onLoad: null, onError: null };
    claim.onLoad = function () { onResponse(claim, 'load'); };
    claim.onError = function () { onResponse(claim, 'error'); };
    markNode(node, claim);
    owner.claim = claim;
    owner.sent = true;
    try {
      node.addEventListener('load', claim.onLoad, false);
      node.addEventListener('error', claim.onError, false);
    } catch (e) {}
    syncBeforeUnload();
    return claim;
  }

  function releaseClaim(claim, mark) {
    if (!claim || !claim.node) return;
    try {
      claim.node.removeEventListener('load', claim.onLoad, false);
      claim.node.removeEventListener('error', claim.onError, false);
    } catch (e) {}
    markNode(claim.node, mark);
    if (claim.watcher && claim.watcher.claim === claim) claim.watcher.claim = null;
    claim.node = null;
    claim.watcher = null;
  }

  function liveWatcherFor(node) {
    var claim = node ? tryClaim(node) : null;
    if (!claim || !claim.watcher || claim.watcher.done) return null;
    return claim.watcher;
  }

  function onResponse(claim, kind) {
    var w = claim && claim.watcher;
    releaseClaim(claim, 'answered');
    if (!w || w.done) return;
    if (kind === 'error') {
      // The request itself failed. ActiveCampaign shows "Sorry, your
      // submission failed" and nothing was submitted.
      finish(w);
      return;
    }
    // The response script has finished running. Anything it did (reveal the
    // thank-you, start a navigation, show an error, throw) has happened.
    w.responseAt = now();
    w.responseSeq = ++eventCount;
    evaluate(w);
    tryRedirectPath(w);
    syncBeforeUnload();
    if (!HAS_NAV_API) {
      try { setTimeout(syncBeforeUnload, NAV_TRUST_MS + 50); } catch (e) {}
    }
  }

  // ---- The thank-you signal (forms set to "Show thank you message") ----
  //
  // ActiveCampaign's `._form-thank-you` element is ALREADY in the form, empty
  // and hidden, before anyone submits. Its presence proves nothing. Success is
  // the change: hidden when this attempt started, then visible with the
  // message in it. It must still be this form's own thank-you element when we
  // look, and one that was already showing when the attempt started can never
  // count.

  function thankYouRevealed(w) {
    var node = w.thankYou;
    if (!node || !w.thankYouHiddenAtArm || w.thankYouLost) return false;
    var owned = false;
    try { owned = node.isConnected === true && w.form.querySelector('._form-thank-you') === node; } catch (e) { owned = false; }
    if (!owned) {
      w.thankYouLost = true;
      return false;
    }
    return !isConcealed(node, w.form) && hasContent(node);
  }

  function evaluate(w) {
    if (!w || w.done) return;
    if (thankYouRevealed(w)) {
      fire(w);
      return;
    }
    if (hasNewServerError(w)) {
      // The server (or the network) refused this attempt.
      finish(w);
      return;
    }
    var fresh = newErrorNodes(w);
    if (!fresh.length) return;
    if (!w.responseAt && w.sent) {
      // The request is already on its way and ActiveCampaign keeps validating
      // fields as the visitor types. A tooltip now is the visitor editing, not
      // the server's answer, so it is set aside and the response decides.
      snapshotErrors(w);
      return;
    }
    // Rejected in the browser: nothing was sent.
    finish(w);
  }

  // ---- The redirect signal (forms set to "Open URL") ----
  //
  // ActiveCampaign's whole response is `window.top.location.href = "<your
  // thank-you URL>"`. Nothing changes in the form. The browser just leaves.

  function identityStillValid(w) {
    try {
      if (!w.form || w.form.isConnected !== true) return false;
      if (numericHiddenF(w.form) !== w.formId) return false;
      var parts = parseUrlParts(String(w.form.action || '').replace(/[?#][\s\S]*$/, ''));
      return isAcProcUrl(parts) && parts.host === w.host;
    } catch (e) {
      return false;
    }
  }

  function tryRedirectPath(w) {
    if (!w || w.done || !w.host || !w.responseAt || w.responseThrew) return;
    // Proof: the navigation started while this attempt's own reply was
    // running. Without the Navigation API, the fallback: a navigation that
    // starts AFTER the reply (in event order, not just by the clock), within
    // NAV_TRUST_MS of it.
    var proven = w.navByResponse;
    if (!proven && !HAS_NAV_API && w.navSeq > w.responseSeq &&
        w.navAt - w.responseAt <= NAV_TRUST_MS) {
      proven = true;
    }
    if (!proven) return;
    if (hasNewServerError(w) || newErrorNodes(w).length) {
      finish(w);
      return;
    }
    if (!identityStillValid(w)) return;
    fire(w);
  }

  // A navigation is starting. If ActiveCampaign's response script for an
  // attempt is the code running right now, that response IS the redirect.
  function onNavigationStart() {
    var cs = safeCurrentScript();
    var owner = cs ? liveWatcherFor(cs) : null;
    if (owner) owner.navByResponse = true;
    var t = now();
    var seq = ++eventCount;
    var pending = watchers.slice();
    for (var i = 0; i < pending.length; i++) {
      pending[i].navAt = t;
      pending[i].navSeq = seq;
      tryRedirectPath(pending[i]);
    }
  }

  function needsBeforeUnload() {
    if (HAS_NAV_API) return false;
    var t = now();
    for (var i = 0; i < watchers.length; i++) {
      var w = watchers[i];
      if (!w.host || !w.sent) continue;
      if (!w.responseAt || t - w.responseAt <= NAV_TRUST_MS) return true;
    }
    return false;
  }

  // Only browsers without the Navigation API need `beforeunload`, and only
  // while a sent attempt could still turn out to be a redirect. It is removed
  // straight after, so it never lingers on the page.
  function syncBeforeUnload() {
    var want = needsBeforeUnload();
    if (want === beforeUnloadOn) return;
    try {
      if (want) window.addEventListener('beforeunload', onNavigationStart, true);
      else window.removeEventListener('beforeunload', onNavigationStart, true);
      beforeUnloadOn = want;
    } catch (e) {}
  }

  // ---- Firing, and tearing an attempt down ----

  function finish(w) {
    if (!w || w.done) return;
    w.done = true;
    try { if (w.observer) w.observer.disconnect(); } catch (e) {}
    w.observer = null;
    if (w.lifeTimer) {
      try { clearTimeout(w.lifeTimer); } catch (e) {}
      w.lifeTimer = null;
    }
    if (w.claim) releaseClaim(w.claim, 'answered');
    for (var i = watchers.length - 1; i >= 0; i--) {
      if (watchers[i] === w) watchers.splice(i, 1);
    }
    w.errorsAtArm = [];
    w.serverErrorsAtArm = [];
    w.thankYou = null;
    syncBeforeUnload();
  }

  function fire(w) {
    if (!w || w.done) return;
    var formId = w.formId;
    var formEl = w.form;
    finish(w);
    recentlyFired.push({ form: formEl, at: now() });
    window.dataLayer.push({
      event: EVENT_NAME,
      form_id: formId
    });
  }

  // ActiveCampaign adds its script inside the submit event, so an attempt can
  // only be matched to a script during the task it was armed in. The window is
  // closed by a zero-delay timer, which runs after that task and after the
  // page observer has seen the script.
  function openClaimWindow(w) {
    w.claimOpen = true;
    try {
      setTimeout(function () { w.claimOpen = false; }, 0);
    } catch (e) {
      w.claimOpen = false;
    }
  }

  function arm(formEl, desc) {
    var w = {
      form: formEl,
      formId: desc.formId,
      host: desc.host,
      seq: ++armCount,
      armedAt: now(),
      claimOpen: false,
      claim: null,
      sent: false,
      responseAt: 0,
      responseSeq: 0,
      responseThrew: false,
      navAt: 0,
      navSeq: 0,
      navByResponse: false,
      done: false,
      observer: null,
      lifeTimer: null,
      errorsAtArm: [],
      serverErrorsAtArm: [],
      thankYou: null,
      thankYouHiddenAtArm: false,
      thankYouLost: false
    };
    snapshotErrors(w);
    var existingErrors = serverErrorNodes(w);
    for (var i = 0; i < existingErrors.length; i++) w.serverErrorsAtArm.push(existingErrors[i]);
    try { w.thankYou = formEl.querySelector('._form-thank-you'); } catch (e) { w.thankYou = null; }
    w.thankYouHiddenAtArm = !!w.thankYou && isConcealed(w.thankYou, formEl);
    watchers.push(w);
    openClaimWindow(w);
    // Nothing ActiveCampaign does takes anywhere near this long, so an attempt
    // still undecided after it is dropped, along with anything it holds.
    try { w.lifeTimer = setTimeout(function () { finish(w); }, WATCH_MS); } catch (e) {}
    try {
      w.observer = new MutationObserver(function () { evaluate(w); });
      w.observer.observe(formEl, {
        childList: true,
        subtree: true,
        characterData: true,
        attributes: true,
        attributeFilter: ['style', 'class', 'hidden', 'aria-hidden']
      });
    } catch (e) {
      w.observer = null;
    }
    syncBeforeUnload();
  }

  // Capture phase on the document, so this runs BEFORE ActiveCampaign's own
  // submit handler on the form. It never fires anything. It only notes that
  // an attempt started and what the form looked like at that moment.
  function handleSubmit(event) {
    try {
      var formEl = event.target;
      var desc = describeForm(formEl);
      if (!desc) return;
      if (firedRecently(formEl)) return;
      var existing = findWatcher(formEl);
      if (existing) {
        if (now() - existing.armedAt < RAPID_RESUBMIT_MS) {
          // The same attempt dispatched again (a double click, or a wrapper
          // re-sending the submit). Keep it, and let this dispatch's script
          // be matched to it if the first one sent nothing.
          if (!existing.sent) openClaimWindow(existing);
          return;
        }
        // Settle the old attempt on what the page shows right now before
        // replacing it, so a success it already earned is not lost.
        evaluate(existing);
        if (!existing.done) finish(existing);
        else if (firedRecently(formEl)) return;
      }
      arm(formEl, desc);
    } catch (e) {
      // Never throw back into ActiveCampaign's own form handling.
    }
  }

  // ============================================================
  // Page-level listeners, installed once
  // ============================================================

  document.addEventListener('submit', handleSubmit, true);

  // Replies to submissions made before this tag loaded belong to nobody.
  try {
    var existingScripts = document.getElementsByTagName('script');
    for (var s = 0; s < existingScripts.length; s++) {
      if (isAcSubmitScript(existingScripts[s])) markNode(existingScripts[s], 'unowned');
    }
  } catch (e) {}

  // Match response scripts the moment ActiveCampaign adds them to the page.
  try {
    var headObserver = new MutationObserver(function (records) {
      for (var i = 0; i < records.length; i++) {
        var added = records[i].addedNodes || [];
        for (var j = 0; j < added.length; j++) tryClaim(added[j]);
      }
    });
    headObserver.observe(document.head || document, { childList: true, subtree: true });
  } catch (e) {}

  // Backstop for a response script that was not matched on insertion.
  function onScriptEvent(event) {
    try {
      var claim = tryClaim(event.target);
      if (claim) onResponse(claim, event.type === 'error' ? 'error' : 'load');
    } catch (e) {}
  }
  document.addEventListener('load', onScriptEvent, true);
  document.addEventListener('error', onScriptEvent, true);

  // A response script that THROWS is never read as a redirect. That is what
  // happens when ActiveCampaign rejects a submission but cannot draw its error
  // box (for example the page has replaced the submit button). The rejection
  // is real, and nothing on screen shows it.
  window.addEventListener('error', function () {
    try {
      var w = liveWatcherFor(safeCurrentScript());
      if (w) w.responseThrew = true;
    } catch (e) {}
  }, true);

  if (HAS_NAV_API) {
    try { window.navigation.addEventListener('navigate', onNavigationStart); } catch (e) {}
  }

  // The page is going away. Browsers without the Navigation API and without
  // `beforeunload` (iOS Safari) get their last chance to see the redirect
  // here, then every attempt still waiting ends with the page.
  window.addEventListener('pagehide', function () {
    try {
      if (!HAS_NAV_API) onNavigationStart();
      var pending = watchers.slice();
      for (var i = 0; i < pending.length; i++) finish(pending[i]);
    } catch (e) {}
  }, true);
})();

Variable

Stores the form number for the submitted form, so you can use it as a filter in any of the conversion tags you build. These are its exact settings:

Name

DLV - ActiveCampaign Form ID

Variable Type

Data Layer Variable

Data Layer Variable Name

form_id

Trigger

Fires whenever the 'activecampaign_form_submitted' event arrives. Use it as the trigger for any of your conversion tags. These are its exact settings:

Name

CE - ActiveCampaign Form Submitted

Trigger Type

Custom Event

Event Name

activecampaign_form_submitted

Download This Recipe

One file holds this whole recipe. Download it, import it into your GTM container, and the listener, variable and trigger walked through above are waiting for you.

Built from Converly's own ActiveCampaign detection code

Why It's the Best Recipe Out There

It's built from the same detection code that powers our own conversion tracking product, Converly, adapted to run inside Google Tag Manager.

Only fires once ActiveCampaign has confirmed the submission. A visitor who fails the form's checks, or whose entry ActiveCampaign's server turns down, is never counted as a conversion.
Works with forms set to show a thank you message on submission, and with forms set to redirect users to a thank you page on submission.
Works with every ActiveCampaign form style (inline, modal, floating box and floating bar) and with both of ActiveCampaign's embed options (Simple Embed and Full Embed).
Has been extensively tested against ActiveCampaign's current form code in Chrome, Firefox and Safari. Has been used to detect thousands of form submissions for Converly customers.

How to Use the ActiveCampaign GTM Recipe

1

Install the Recipe

Download the recipe from the form above, then import it into your Google Tag Manager container. In GTM, open Admin, click Import Container, pick the file you downloaded, and choose the Merge option so nothing already in your container is overwritten. The listener, variable and trigger are then added to your account automatically.

Importing the recipe container in Google Tag Manager
2

Configure Your Tags

Once the recipe is installed, you can use it as the Trigger for your conversion tags. Here are two popular examples.

Example 1: Google Analytics

Create a GA4 event tag and pick the recipe's trigger (called CE - ActiveCampaign Form Submitted) as its firing trigger. After that, every confirmed ActiveCampaign form submission sends an event to Google Analytics.

A GA4 event tag using the recipe's trigger

Example 2: Google Ads

Build a Google Ads conversion tag and have it fire on the recipe's trigger (called CE - ActiveCampaign Form Submitted), so every confirmed ActiveCampaign form submission is sent to Google Ads as a conversion.

A Google Ads conversion tag using the recipe's trigger

What This Enables You to Do (and What It Doesn't)

Whenever ActiveCampaign confirms a form submission on your site, this recipe fires an event in Google Tag Manager. That event becomes the trigger for your Google Ads, Google Analytics or other conversion tags.

What it does

Sends an Event to GTM

Detects successful ActiveCampaign form submissions and pushes an event called 'activecampaign_form_submitted' into your dataLayer for you.

Tells You Which Form Fired

Captures the ID of the ActiveCampaign form that was submitted and passes it along with the 'activecampaign_form_submitted' event, so a site with several forms can fire different tags for each one.

Creates a Trigger in GTM

Adds a 'Trigger' to Google Tag Manager that you can use as the trigger for any of your conversion tags.

What it doesn't do

Send the conversion server-side

Everything happens in the visitor's browser, which means it only works if the browser lets it. Ad blockers can stop the Google tag loading (which means nothing the visitor does gets tracked), and browser privacy rules can mean the visitor can only be tracked for a day (so if they click your ad on Monday but don't convert until Thursday, the conversion can't be tied back to the original ad click).

No Click IDs or Identifiers

It doesn't pick up the Google click ID, Facebook click ID, Meta pixel cookies, IP address or user agent, all of which ad platforms rely on for Enhanced Conversions (Google) or a solid Event Match Quality score (Meta).

No Personal Details

All the script does is fire an event. It doesn't send the lead's name, email or phone number, which Google and Meta both need to match the conversion back to a real person and the original ad they clicked.

No Logging or Notifications

You can use Tag Assistant to test it works when you first set it up, but unless you're testing every week, it can easily break without you knowing (you'll just start to see 0 conversions in your ad platforms).

The Proper Way to Send Conversions to Google Ads, Meta Ads & More

If Google Analytics is your only destination, this recipe will serve you fine because GA won't take personal information or IP addresses in the first place. But if you want to set up proper conversion tracking in ad platforms like Google, Meta, ChatGPT, etc., then you need to send the lead's name, email, phone, etc straight to their servers. That's what Converly does.

A Converly workflow sending ActiveCampaign conversions to ad platforms

Here's why Converly is the best way to get conversions into Google Ads, Meta Ads, Microsoft Ads, LinkedIn Ads, etc.

Easy to set up

Converly gives you a drag-and-drop builder for putting together a Conversion Flow. Choose a trigger (like an ActiveCampaign form being submitted), then choose your actions (like sending a conversion to Google Ads). There's no custom code to write and nothing complicated to configure.

Sends Data Server-Side

Instead of going through the visitor's browser, Converly sends every conversion from its own servers straight to Google, Meta, ChatGPT, etc. That means ad blockers and privacy restrictions can't get in the way, and you can get much more accurate conversion tracking.

Sends More Data

It collects the lead's name, email and phone along with the Google and Facebook click IDs, IP address and user agent, and sends them with each conversion. That switches on Enhanced Conversions in Google Ads, or lifts your Event Match Quality (EMQ) score in Meta to 9 or 10.

Full Conversion Log & Email Notifications

See every conversion, what was captured, what was sent to each platform and whether it arrived. Email alerts mean your tracking can't break without you knowing.

Supports Multiple Tools & Destinations

Detects submissions from 100+ form, scheduling and chat tools and sends them to 20+ destinations including Google Ads, Meta, ChatGPT, LinkedIn, Microsoft and TikTok. So adding a new tool or advertising on a new platform only takes a few clicks.

Provides Free Support

Our team brings 20 years of conversion tracking and analytics experience and is on hand whenever you need help. With Google Tag Manager, you're on your own.

Start Your Free Trial

Start a 14-day free trial and track your conversions the proper way. No code, no complicated configuration.

About the author

Aaron Beashel
Aaron Beashel

Aaron is the founder of Converly. With over 15 years of experience in digital marketing and SaaS, he's passionate about helping businesses track and optimise their ad conversions.

Start your free trial

Easily send conversions to your ad platforms and analytics tools.
No code required.