Skill Tracer
Skills › Marketing Skills › conversion-tracking › conversion-moments.md

conversion-moments.md

318 lines · skills/conversion-tracking/references/conversion-moments.md at 88c79f6

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# Conversion moments: how each tool signals success
2
3Load this to establish or diagnose the conversion moment. Section 1 is the lead-gen problem (success must be inferred from a browser signal, and every tool signals differently); it carries literal, grep-able strings for working from (A) form mechanics, (B) static page source, (C) a headless browser, and (D) a codebase. Section 2 covers the moments this repo ships tested detectors for. Section 3 is the long tail. Section 4 is ecommerce, where the problem mostly does not exist.
4
5**Fix assets.** When a finding here names a missing or wrong detection hook, the fix usually already exists in this skill. Tested listeners for 22 form and booking tools and 4 universal patterns (26 in all) live in `../assets/snippets/` (paste-in JS) and `../assets/gtm-recipes/detect/` (importable GTM containers), both pushing the canonical dataLayer event names listed in `../assets/gtm-recipes/event-map.json` (for example `gravity_form_submitted`, `typeform_form_submitted`, `calendly_event_scheduled`). Diagnose against those exact names, fix by installing those exact assets.
6

Conversion moments: how each tool signals success

Load this to establish or diagnose the conversion moment. Section 1 is the lead-gen problem (success must be inferred from a browser signal, and every tool signals differently); it carries literal, grep-able strings for working from (A) form mechanics, (B) static page source, (C) a headless browser, and (D) a codebase. Section 2 covers the moments this repo ships tested detectors for. Section 3 is the long tail. Section 4 is ecommerce, where the problem mostly does not exist.

Fix assets. When a finding here names a missing or wrong detection hook, the fix usually already exists in this skill. Tested listeners for 22 form and booking tools and 4 universal patterns (26 in all) live in ../assets/snippets/ (paste-in JS) and ../assets/gtm-recipes/detect/ (importable GTM containers), both pushing the canonical dataLayer event names listed in ../assets/gtm-recipes/event-map.json (for example gravity_form_submitted, typeform_form_submitted, calendly_event_scheduled). Diagnose against those exact names, fix by installing those exact assets.

Raw lines 1 to 6
7## 1. Lead gen
8

1. Lead gen

Raw lines 7 to 8
9### A. Form submission mechanics
11#### A.1 The four fundamental patterns
13| Pattern | What happens | Why tracking breaks | Correct hook |
14|---|---|---|---|
15| Classic POST + redirect | server 302s to /thank-you | Works IF tags exist on thank-you page; risks: direct-reachable thank-you (false conversions), missing tags there, redirect losing params | Pageview trigger on thank-you URL |
16| AJAX/fetch + inline success | JS intercepts, URL never changes | No pageview → destination-page tracking counts ZERO silently. #1 failure mode | dataLayer push in success callback; Element Visibility on success message; platform events |
17| Third-party iframe embed | form on vendor's domain in iframe | Parent GTM blind to iframe internals; iframe can't read parent cookies; tracking inside attributes to vendor domain | postMessage listener for vendor events; vendor redirect to first-party thank-you; vendor webhook → server-side |
18| Redirect to external processor | Stripe Checkout, Calendly full-page, formspree.io, formsubmit.co, Netlify Forms | Conversion completes on uncontrolled domain | Processor's redirect-after-success back to first-party /thank-you; else webhook → server-side APIs |
20Stacking complications: multi-step forms (only final step converts; step events mistaken for submits; attribution scripts running only on step 1); popup/modal forms (not in DOM at load - delegated listeners or Element Visibility with "observe DOM changes"); full-page-reload confirmations with no URL change (default WPForms/Gravity non-AJAX - only hook is the confirmation div).
22#### A.2 Per-platform catalog (literal strings)
24**Typeform** - iframe. Embed: `embed.typeform.com/next/embed.js`; attrs `data-tf-live=`, `data-tf-widget=`; iframe src `form.typeform.com/to/<id>`. Parent receives message events from origin `https://form.typeform.com` with `event.data.type === "form-submit"` (also "form-started", "form-screen-changed"), payload has `formId`. Embed SDK: `onSubmit` callback with {formId, responseId}. Alternative: Ending screen redirect to first-party thank-you.
26**Calendly** - iframe. Script `assets.calendly.com/assets/external/widget.js`; `<div class="calendly-inline-widget" data-url=...>`. Parent messages where `e.data.event` starts with `"calendly."`: profile_page_viewed, event_type_viewed, date_and_time_selected, **event_scheduled** (the conversion). Payload has event.uri + invitee.uri only - NO email (enhanced conversions need invitee API or prefill capture). Beware: date_and_time_selected ≠ booked.
28**Cal.com** - iframe. Cal.com's embed code (inline, popup, floating button) renders from `https://app.cal.com`, and a booking page pasted in as a plain iframe loads from `https://cal.com`. A new booking posts `{originator: 'CAL', type: 'bookingSuccessfulV2', data: {uid, eventTypeId, status, ...}}` to the parent from the frame's own origin. `cal-com.js` fires once per booking on `bookingSuccessfulV2`, accepted only from a Cal.com iframe on the page, and trusts each frame only for the origin its address starts with, so both setups work. Reschedules never count, and the deprecated `bookingSuccessful` (which carries the attendee's name and email) is never used. Bookings the host must confirm fire straight away with `booking_status` `PENDING`; add a trigger condition on `ACCEPTED` to count only confirmed ones. It doesn't cover self-hosted Cal.com. It was live-checked 15 Oct 2026 with real bookings through all four setups (inline, popup, floating button and plain iframe).
30**Acuity Scheduling** - iframe, and Acuity tells the page nothing (its embed script only posts resize messages). Its Custom Conversion Tracking field (Integrations, then Custom Conversion Tracking) runs HTML after each booking in a sandbox frame on `conversion-tracking-sandbox.acuityinnovation.com`, two frames below the page, with `%id%`, `%type%`, `%appointmentType%` and `%price%` filled in. This is the shipped example of Shape 3 below, where half the code goes inside the vendor's tool. `acuity-scheduling-field.html` goes in that field and posts those four values to the site's own address (put that address in `SITES`, or nothing counts), and `acuity-scheduling.js` on the site accepts only HTTPS `acuityinnovation.com` frames inside the page, once per appointment ID. Acuity keeps about 2 KB in the field, and the snippet is about 1 KB. Reschedules never count. Package and gift certificate purchases fire too, so filter on `acuity_type` equals `appointment`. Public snippets that read `%email%` and post to `"*"` send the visitor's email to the top window whatever site it is showing. This one never reads the name, email or phone. It was live-checked 15 Oct 2026 with two real bookings made by hand, because of reCAPTCHA, and a refused email counted nothing.
32**HubSpot Forms** - TWO variants, check BOTH:
33- Legacy v2: `js.hsforms.net/forms/embed/v2.js` + `hbspt.forms.create({portalId, formId})`; renders INLINE mostly. Window message events: `event.data.type === 'hsFormCallback'`, `event.data.eventName` ∈ onFormReady | onFormSubmit | **onFormSubmitted** (use this, post-success) | onFormFailedValidation; `event.data.id` = form GUID.
34- New v4: `js.hsforms.net/forms/embed/<portalId>.js` + `<div class="hs-form-frame" data-form-id=...>`; renders in IFRAME. Real DOM events on window: `hs-form-event:on-ready`, **`hs-form-event:on-submission:success`**, `:failed`, `:on-interaction:navigate`; `event.detail.formId`; helper `window.HubSpotFormsV4` (has getConversionId()).
35- Note: `js.hs-scripts.com/<portalId>.js` is the general HubSpot tracking script - presence ≠ ad conversion tracking.
36- `hubspot-forms.js` covers both and pushes `hubspot_form_submitted` with `form_id` only, never the lead's details. On a current-editor form with the current embed code it counts `hs-form-event:on-submission:success` only when `HubSpotFormsV4.getFormFromEvent` confirms the form and instance, once per conversion ID. On a legacy-editor form with the v2.js embed it counts only an `onFormSubmitted` message posted from the page's own window, never `onFormSubmit`, which also fires for submissions that then fail. Legacy forms in a cross-origin iframe aren't counted, because their message can't be tied to a form. A current-editor form placed with the older `hbspt.forms.create()` code sent the same success event in an earlier probe but wasn't in the 15 Oct checks, and multi-step forms, several forms on one page, redirect after submit and pop-up forms weren't tested. Submissions HubSpot's bot filter files under Spam still count, because HubSpot fires its success event for them too. It was live-checked 15 Oct 2026 on a current-editor form and a legacy-editor form, and again on 16 Oct after a retry fix, and HubSpot's Submissions matched the events one for one.
38**Jotform** - iframe (`form.jotform.com/<id>` or script `form.jotform.com/jsform/<id>`). After a successful submission, Jotform's iframe and script embeds send the parent page a `{action: 'submission-completed', formID}` message (live-checked on both, Oct 2026; Jotform support describes it but doesn't formally document it), which `jotform.js` listens for. The full source-code embed sends none. The Thank You → redirect setting breaks out of the iframe, navigates TOP window to your URL, and can append field values + unique {id} param - redirect is the most robust method. Tags pasted inside the Jotform builder fire on jotform.com's domain (wrong context).
40**Contact Form 7 (WP)** - always AJAX. Bubbling DOM events on document (plain addEventListener works): **`wpcf7mailsent`** (success - use this), wpcf7submit, wpcf7invalid, wpcf7spam, wpcf7mailfailed. `event.detail` includes contactFormId, inputs (name/value array - usable for enhanced conversions).
42**Gravity Forms (WP)** - Confirmation types: Text (default), Page, Redirect. WITH AJAX embed: jQuery event **`gform_confirmation_loaded`** on document (formId arg) - jQuery-triggered, NOT catchable via addEventListener. Multi-page: gform_page_loaded. WITHOUT AJAX + Text confirmation: full reload to same URL - no event, no new pageview; only hook is `.gform_confirmation_message` / `#gform_confirmation_wrapper_<id>` via Element Visibility. Redirect confirmation → classic pattern.
44**WPForms (WP)** - default non-AJAX POST + reload, same-URL confirmation `.wpforms-confirmation-container-full`; optional AJAX fires jQuery **`wpformsAjaxSubmitSuccess`**. Confirmation types: Message / Show Page / Go to URL.
46**Elementor Pro Forms (WP)** - AJAX; jQuery-triggered **`submit_success`** on document - `jQuery(document).on('submit_success', fn)`; vanilla addEventListener will NOT catch it (common audit finding). evt.target.id identifies form. Popup forms injected on open - document-level delegation still catches. Optional Redirect action → classic.
48**Webflow** - native forms `<form data-name=...>` in `.w-form`; AJAX submit; success hides form, reveals sibling **`div.w-form-done`**; failure `.w-form-fail`. NO dataLayer event, NO URL change by default. Hooks: Element Visibility on .w-form-done, custom listener, or redirect setting.
50**Framer** - React forms, inline success swap, no navigation, no built-in GTM event. Track via custom listener/element observation or redirect. Framer can auto-capture UTM/GCLID into form fields (built-in feature).
52**Squarespace** - AJAX + inline message default. A 7.1 Form block renders in the page (no iframe) and saves through `/api/form/SaveFormSubmission`, and Squarespace's server decides (204 saved, 400 field error, 401 failed reCAPTCHA), so a submit alone is never a conversion. `squarespace-forms.js` fires only once Squarespace confirms the entry was saved, once per form per page view, and also works for forms set to redirect. It doesn't cover lightbox forms, 7.0 forms, or commerce or member forms. It was live-checked 15 Oct 2026 on inline, redirect and shared-page forms in Chromium, WebKit and Firefox. The other options are a redirect URL, or the "Post-Submit HTML" field that injects and executes code only after successful submit (a de facto conversion hook; Squarespace gates JavaScript and code injection to Core plans and above, and its form docs state no separate gate for this field).
54**Wix** - AJAX + inline success; tracking via Wix's own dashboard integrations (auto-fires form-submit conversion) or Velo `wixWindow.trackEvent`; raw GTM DOM listeners unreliable in Wix's framework. "Wix + manually pasted pixel" = high risk of no submit event.

A. Form submission mechanics

A.1 The four fundamental patterns

PatternWhat happensWhy tracking breaksCorrect hook
Classic POST + redirectserver 302s to /thank-youWorks IF tags exist on thank-you page; risks: direct-reachable thank-you (false conversions), missing tags there, redirect losing paramsPageview trigger on thank-you URL
AJAX/fetch + inline successJS intercepts, URL never changesNo pageview → destination-page tracking counts ZERO silently. #1 failure modedataLayer push in success callback; Element Visibility on success message; platform events
Third-party iframe embedform on vendor's domain in iframeParent GTM blind to iframe internals; iframe can't read parent cookies; tracking inside attributes to vendor domainpostMessage listener for vendor events; vendor redirect to first-party thank-you; vendor webhook → server-side
Redirect to external processorStripe Checkout, Calendly full-page, formspree.io, formsubmit.co, Netlify FormsConversion completes on uncontrolled domainProcessor's redirect-after-success back to first-party /thank-you; else webhook → server-side APIs

Stacking complications: multi-step forms (only final step converts; step events mistaken for submits; attribution scripts running only on step 1); popup/modal forms (not in DOM at load - delegated listeners or Element Visibility with "observe DOM changes"); full-page-reload confirmations with no URL change (default WPForms/Gravity non-AJAX - only hook is the confirmation div).

A.2 Per-platform catalog (literal strings)

Typeform - iframe. Embed: embed.typeform.com/next/embed.js; attrs data-tf-live=, data-tf-widget=; iframe src form.typeform.com/to/<id>. Parent receives message events from origin https://form.typeform.com with event.data.type === "form-submit" (also "form-started", "form-screen-changed"), payload has formId. Embed SDK: onSubmit callback with {formId, responseId}. Alternative: Ending screen redirect to first-party thank-you.

Calendly - iframe. Script assets.calendly.com/assets/external/widget.js; <div class="calendly-inline-widget" data-url=...>. Parent messages where e.data.event starts with "calendly.": profile_page_viewed, event_type_viewed, date_and_time_selected, event_scheduled (the conversion). Payload has event.uri + invitee.uri only - NO email (enhanced conversions need invitee API or prefill capture). Beware: date_and_time_selected ≠ booked.

Cal.com - iframe. Cal.com's embed code (inline, popup, floating button) renders from https://app.cal.com, and a booking page pasted in as a plain iframe loads from https://cal.com. A new booking posts {originator: 'CAL', type: 'bookingSuccessfulV2', data: {uid, eventTypeId, status, ...}} to the parent from the frame's own origin. cal-com.js fires once per booking on bookingSuccessfulV2, accepted only from a Cal.com iframe on the page, and trusts each frame only for the origin its address starts with, so both setups work. Reschedules never count, and the deprecated bookingSuccessful (which carries the attendee's name and email) is never used. Bookings the host must confirm fire straight away with booking_status PENDING; add a trigger condition on ACCEPTED to count only confirmed ones. It doesn't cover self-hosted Cal.com. It was live-checked 15 Oct 2026 with real bookings through all four setups (inline, popup, floating button and plain iframe).

Acuity Scheduling - iframe, and Acuity tells the page nothing (its embed script only posts resize messages). Its Custom Conversion Tracking field (Integrations, then Custom Conversion Tracking) runs HTML after each booking in a sandbox frame on conversion-tracking-sandbox.acuityinnovation.com, two frames below the page, with %id%, %type%, %appointmentType% and %price% filled in. This is the shipped example of Shape 3 below, where half the code goes inside the vendor's tool. acuity-scheduling-field.html goes in that field and posts those four values to the site's own address (put that address in SITES, or nothing counts), and acuity-scheduling.js on the site accepts only HTTPS acuityinnovation.com frames inside the page, once per appointment ID. Acuity keeps about 2 KB in the field, and the snippet is about 1 KB. Reschedules never count. Package and gift certificate purchases fire too, so filter on acuity_type equals appointment. Public snippets that read %email% and post to "*" send the visitor's email to the top window whatever site it is showing. This one never reads the name, email or phone. It was live-checked 15 Oct 2026 with two real bookings made by hand, because of reCAPTCHA, and a refused email counted nothing.

HubSpot Forms - TWO variants, check BOTH:

  • Legacy v2: js.hsforms.net/forms/embed/v2.js + hbspt.forms.create({portalId, formId}); renders INLINE mostly. Window message events: event.data.type === 'hsFormCallback', event.data.eventName ∈ onFormReady | onFormSubmit | onFormSubmitted (use this, post-success) | onFormFailedValidation; event.data.id = form GUID.
  • New v4: js.hsforms.net/forms/embed/<portalId>.js + <div class="hs-form-frame" data-form-id=...>; renders in IFRAME. Real DOM events on window: hs-form-event:on-ready, **hs-form-event:on-submission:success**, :failed, :on-interaction:navigate; event.detail.formId; helper window.HubSpotFormsV4 (has getConversionId()).
  • Note: js.hs-scripts.com/<portalId>.js is the general HubSpot tracking script - presence ≠ ad conversion tracking.
  • hubspot-forms.js covers both and pushes hubspot_form_submitted with form_id only, never the lead's details. On a current-editor form with the current embed code it counts hs-form-event:on-submission:success only when HubSpotFormsV4.getFormFromEvent confirms the form and instance, once per conversion ID. On a legacy-editor form with the v2.js embed it counts only an onFormSubmitted message posted from the page's own window, never onFormSubmit, which also fires for submissions that then fail. Legacy forms in a cross-origin iframe aren't counted, because their message can't be tied to a form. A current-editor form placed with the older hbspt.forms.create() code sent the same success event in an earlier probe but wasn't in the 15 Oct checks, and multi-step forms, several forms on one page, redirect after submit and pop-up forms weren't tested. Submissions HubSpot's bot filter files under Spam still count, because HubSpot fires its success event for them too. It was live-checked 15 Oct 2026 on a current-editor form and a legacy-editor form, and again on 16 Oct after a retry fix, and HubSpot's Submissions matched the events one for one.

Jotform - iframe (form.jotform.com/<id> or script form.jotform.com/jsform/<id>). After a successful submission, Jotform's iframe and script embeds send the parent page a {action: 'submission-completed', formID} message (live-checked on both, Oct 2026; Jotform support describes it but doesn't formally document it), which jotform.js listens for. The full source-code embed sends none. The Thank You → redirect setting breaks out of the iframe, navigates TOP window to your URL, and can append field values + unique {id} param - redirect is the most robust method. Tags pasted inside the Jotform builder fire on jotform.com's domain (wrong context).

Contact Form 7 (WP) - always AJAX. Bubbling DOM events on document (plain addEventListener works): **wpcf7mailsent** (success - use this), wpcf7submit, wpcf7invalid, wpcf7spam, wpcf7mailfailed. event.detail includes contactFormId, inputs (name/value array - usable for enhanced conversions).

Gravity Forms (WP) - Confirmation types: Text (default), Page, Redirect. WITH AJAX embed: jQuery event **gform_confirmation_loaded** on document (formId arg) - jQuery-triggered, NOT catchable via addEventListener. Multi-page: gform_page_loaded. WITHOUT AJAX + Text confirmation: full reload to same URL - no event, no new pageview; only hook is .gform_confirmation_message / #gform_confirmation_wrapper_<id> via Element Visibility. Redirect confirmation → classic pattern.

WPForms (WP) - default non-AJAX POST + reload, same-URL confirmation .wpforms-confirmation-container-full; optional AJAX fires jQuery **wpformsAjaxSubmitSuccess**. Confirmation types: Message / Show Page / Go to URL.

Elementor Pro Forms (WP) - AJAX; jQuery-triggered **submit_success** on document - jQuery(document).on('submit_success', fn); vanilla addEventListener will NOT catch it (common audit finding). evt.target.id identifies form. Popup forms injected on open - document-level delegation still catches. Optional Redirect action → classic.

Webflow - native forms <form data-name=...> in .w-form; AJAX submit; success hides form, reveals sibling **div.w-form-done**; failure .w-form-fail. NO dataLayer event, NO URL change by default. Hooks: Element Visibility on .w-form-done, custom listener, or redirect setting.

Framer - React forms, inline success swap, no navigation, no built-in GTM event. Track via custom listener/element observation or redirect. Framer can auto-capture UTM/GCLID into form fields (built-in feature).

Squarespace - AJAX + inline message default. A 7.1 Form block renders in the page (no iframe) and saves through /api/form/SaveFormSubmission, and Squarespace's server decides (204 saved, 400 field error, 401 failed reCAPTCHA), so a submit alone is never a conversion. squarespace-forms.js fires only once Squarespace confirms the entry was saved, once per form per page view, and also works for forms set to redirect. It doesn't cover lightbox forms, 7.0 forms, or commerce or member forms. It was live-checked 15 Oct 2026 on inline, redirect and shared-page forms in Chromium, WebKit and Firefox. The other options are a redirect URL, or the "Post-Submit HTML" field that injects and executes code only after successful submit (a de facto conversion hook; Squarespace gates JavaScript and code injection to Core plans and above, and its form docs state no separate gate for this field).

Wix - AJAX + inline success; tracking via Wix's own dashboard integrations (auto-fires form-submit conversion) or Velo wixWindow.trackEvent; raw GTM DOM listeners unreliable in Wix's framework. "Wix + manually pasted pixel" = high risk of no submit event.

Raw lines 9 to 55
56### B. Static detection signatures (grep fetched HTML + inline JS)
58| Technology | Grep for | Notes |
59|---|---|---|
60| gtag.js | `googletagmanager.com/gtag/js?id=` ; `gtag('config'` ; `window.dataLayer` | `G-` GA4 only, `AW-` Google Ads. Cookies: _ga, _gcl_au, _gcl_aw |
61| Google Ads conversion | `AW-` ; `gtag('event', 'conversion'` ; `'send_to': 'AW-` | AW- config with NO send_to event anywhere = remarketing only; conversion may exist only in GTM |
62| GTM | `GTM-` ; `googletagmanager.com/gtm.js?id=` ; noscript `ns.html?id=` | GTM present ≠ conversion tag present (see B.2) |
63| Meta Pixel | `connect.facebook.net/en_US/fbevents.js` ; `fbq('init'` ; `fbq('track'` ; `facebook.com/tr?id=` | Only Lead/custom events = conversions; bare PageView isn't. Cookies _fbp, _fbc |
64| TikTok | `analytics.tiktok.com/i18n/pixel/events.js` ; `ttq.load(` ; `ttq.track(` | Cookie _ttp |
65| LinkedIn | `_linkedin_partner_id` ; `snap.licdn.com/li.lms-analytics/insight.min.js` ; `px.ads.linkedin.com/collect?pid=` | Cookie li_fat_id |
66| Microsoft UET | `bat.bing.com/bat.js` ; `window.uetq` ; `UET({ti:` | Cookies _uetsid, _uetvid |
67| Cookiebot | `consent.cookiebot.com/uc.js` ; `data-cbid=` | CMPs may block tags pre-consent |
68| OneTrust | `cdn.cookielaw.org` ; `otSDKStub.js` ; `OptanonWrapper` | Cookie OptanonConsent |
69| CookieYes | `cdn-cookieyes.com/client_data/` | |
70| Other CMPs | `app.usercentrics.eu` ; `cmp.osano.com` ; `app.termly.io/embed` ; `complianz` ; `klaro` | |
71| Consent Mode | `gtag('consent', 'default'` ; `'ad_storage'` | denied default + no CMP wiring = silent loss |
72| sGTM / Stape | `gtm.js`/`/gtag/js` loaded from NON-Google domain (subdomains like load., sgtm., ss., data., track., metrics.); `server_container_url` ; `transport_url` ; custom loader = own-domain script with randomized path | Runtime cookie FPID = strong sGTM signal; GA hits to `<subdomain>/g/collect` |
73| Form embeds | `embed.typeform.com` ; `form.typeform.com/to/` ; `assets.calendly.com` ; `calendly-inline-widget` ; `js.hsforms.net/forms/embed/` ; `hbspt.forms.create` ; `hs-form-frame` ; `form.jotform.com` | Embed present + only pageview tags = likely broken |
74| WP form plugins | `wp-content/plugins/contact-form-7/` ; `wpcf7` ; `gform_wrapper` ; `wpforms-` ; `elementor-form` | Tells you which §A event to expect |
75| Other analytics | `cdn.segment.com` ; `plausible.io/js` ; `posthog` ; `static.hotjar.com` ; `clarity.ms/tag` | Context |
76| Attribution capture | hidden inputs named `utm_source`/`gclid` in forms; third-party attribution-capture scripts | Attribution-capture attempt |
78#### B.1 What static fetch CANNOT tell you
791. Whether events actually fire on submit (wiring, races, JS errors)
802. Whether consent banner blocks tags for unconsented users
813. What's inside GTM - until you fetch the container (B.2)
824. Tags injected server-side per page (fetch /thank-you variants too)
835. Server-side conversions (CAPI, offline uploads) - invisible; look for sGTM signals or ask for codebase
85#### B.2 Reading the GTM container WITHOUT account access
86The container is a public JS file: fetch `https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX`. It embeds `{"resource":{"macros":[...], "tags":[...], "predicates":[...], "rules":[...]}}` - the full config. Greps:
87- `"function":"__awct"` → Google Ads conversion tag EXISTS; nearby `vtp_conversionId`/`vtp_conversionLabel` = exact AW-id/label
88- `__gaawe` GA4 event tag; `__gaawc`/`__googtag` config; `__sp` Ads remarketing; `__flc` Floodlight; `__html` Custom HTML (pixel/CAPI glue); `__cvt_` custom templates (Facebook/TikTok/Stape)
89- Triggers: `__fsl` form submit listener; `__cl`/`__lcl` clicks; `__evl` element visibility; `__hl` history change (SPA)
90- Predicates: `"arg1":"gtm.formSubmit"`, custom event names like `"arg1":"typeform_submit"` - exactly which dataLayer event the conversion tag waits for; then verify the page actually pushes it
91- **GTM expresses configuration errors as ABSENCE, and this is the single most important
92 thing to understand about reading a published container.** A tag that is misconfigured is
93 not published as a broken tag. It is not published at all. Verified causes of a tag
94 vanishing from `gtm.js` while the rest of the container compiles normally:
95 - the tag is **paused**
96 - a **required field is empty** (an Ads tag with a blank conversion label)
97 - a **field holds an invalid value** (a conversion ID pasted as `AW-123123123` rather than
98 the digits alone)
99 - the tag has **no firing trigger** attached
101 Verified further by trying and failing to build the opposite: GTM will not publish a
102 conversion tag with an empty currency field, nor a GA4 event tag with an empty measurement
103 ID. These are not faults you will ever find in a published container, because the container
104 cannot contain them. If a user describes a symptom that would require one, the tag is either
105 unpublished, paused, or was never saved.
107 So `__awct` absent means **no conversion tag is live**. It does not mean no tag exists, and
108 it never justifies telling a user who built one that they have none. Every one of these is
109 invisible from outside and only the API separates them (see the container access note in `audit-playbook.md`). This is also why a
110 user insisting "the tag is right there, I'm looking at it" is usually telling the truth.
112- **Paused tags are invisible here, and `__paused` is a trap.** GTM omits a paused tag from the published container entirely rather than flagging it. Verified on two otherwise identical containers: the live one carries `__awct`, its conversion ID and its label; the paused one carries none of the three.
113 **Do not read `__paused:1` as evidence.** It appears in every container as part of GTM's internal tag-type registry, next to `__tl`, `__tg` and `__ytl`. It says a tag type exists in GTM, not that this container has a paused tag. An agent under test reached the right answer by misreading exactly this, which is luck rather than diagnosis and will produce a confident wrong answer on the next container.
114 When a user believes a tag exists and you cannot find one, say no tag is **live**, name paused as a likely cause, and confirm through the API (see `audit-playbook.md`). Never present it as something you observed in `gtm.js`.
115- Grep whole file for `AW-`, `conversionLabel`, pixel IDs. Nothing matching = container has NO conversion tags regardless of what the page promises.
117#### B.3 How existing detectors work
118Wappalyzer/BuiltWith: regex DB over script srcs, HTML, JS globals, cookies. Tag Assistant: instruments live page, shows fired tags + hit params. Meta Ads Data Advisor (formerly Pixel Helper): checks fbq state + observed /tr requests. Headless equivalent: watch network + evaluate `fbq.getState()`.

B. Static detection signatures (grep fetched HTML + inline JS)

TechnologyGrep forNotes
gtag.jsgoogletagmanager.com/gtag/js?id= ; gtag('config' ; window.dataLayerG- GA4 only, AW- Google Ads. Cookies: _ga, _gcl_au, _gcl_aw
Google Ads conversionAW- ; gtag('event', 'conversion' ; 'send_to': 'AW-AW- config with NO send_to event anywhere = remarketing only; conversion may exist only in GTM
GTMGTM- ; googletagmanager.com/gtm.js?id= ; noscript ns.html?id=GTM present ≠ conversion tag present (see B.2)
Meta Pixelconnect.facebook.net/en_US/fbevents.js ; fbq('init' ; fbq('track' ; facebook.com/tr?id=Only Lead/custom events = conversions; bare PageView isn't. Cookies _fbp, _fbc
TikTokanalytics.tiktok.com/i18n/pixel/events.js ; ttq.load( ; ttq.track(Cookie _ttp
LinkedIn_linkedin_partner_id ; snap.licdn.com/li.lms-analytics/insight.min.js ; px.ads.linkedin.com/collect?pid=Cookie li_fat_id
Microsoft UETbat.bing.com/bat.js ; window.uetq ; UET({ti:Cookies _uetsid, _uetvid
Cookiebotconsent.cookiebot.com/uc.js ; data-cbid=CMPs may block tags pre-consent
OneTrustcdn.cookielaw.org ; otSDKStub.js ; OptanonWrapperCookie OptanonConsent
CookieYescdn-cookieyes.com/client_data/
Other CMPsapp.usercentrics.eu ; cmp.osano.com ; app.termly.io/embed ; complianz ; klaro
Consent Modegtag('consent', 'default' ; 'ad_storage'denied default + no CMP wiring = silent loss
sGTM / Stapegtm.js//gtag/js loaded from NON-Google domain (subdomains like load., sgtm., ss., data., track., metrics.); server_container_url ; transport_url ; custom loader = own-domain script with randomized pathRuntime cookie FPID = strong sGTM signal; GA hits to <subdomain>/g/collect
Form embedsembed.typeform.com ; form.typeform.com/to/ ; assets.calendly.com ; calendly-inline-widget ; js.hsforms.net/forms/embed/ ; hbspt.forms.create ; hs-form-frame ; form.jotform.comEmbed present + only pageview tags = likely broken
WP form pluginswp-content/plugins/contact-form-7/ ; wpcf7 ; gform_wrapper ; wpforms- ; elementor-formTells you which §A event to expect
Other analyticscdn.segment.com ; plausible.io/js ; posthog ; static.hotjar.com ; clarity.ms/tagContext
Attribution capturehidden inputs named utm_source/gclid in forms; third-party attribution-capture scriptsAttribution-capture attempt

B.1 What static fetch CANNOT tell you

  1. Whether events actually fire on submit (wiring, races, JS errors)
  2. Whether consent banner blocks tags for unconsented users
  3. What's inside GTM - until you fetch the container (B.2)
  4. Tags injected server-side per page (fetch /thank-you variants too)
  5. Server-side conversions (CAPI, offline uploads) - invisible; look for sGTM signals or ask for codebase

B.2 Reading the GTM container WITHOUT account access

The container is a public JS file: fetch https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX. It embeds {"resource":{"macros":[...], "tags":[...], "predicates":[...], "rules":[...]}} - the full config. Greps:

  • "function":"__awct" → Google Ads conversion tag EXISTS; nearby vtp_conversionId/vtp_conversionLabel = exact AW-id/label
  • __gaawe GA4 event tag; __gaawc/__googtag config; __sp Ads remarketing; __flc Floodlight; __html Custom HTML (pixel/CAPI glue); __cvt_ custom templates (Facebook/TikTok/Stape)
  • Triggers: __fsl form submit listener; __cl/__lcl clicks; __evl element visibility; __hl history change (SPA)
  • Predicates: "arg1":"gtm.formSubmit", custom event names like "arg1":"typeform_submit" - exactly which dataLayer event the conversion tag waits for; then verify the page actually pushes it
  • GTM expresses configuration errors as ABSENCE, and this is the single most important thing to understand about reading a published container. A tag that is misconfigured is not published as a broken tag. It is not published at all. Verified causes of a tag vanishing from gtm.js while the rest of the container compiles normally:
  • the tag is paused
  • a required field is empty (an Ads tag with a blank conversion label)
  • a field holds an invalid value (a conversion ID pasted as AW-123123123 rather than the digits alone)
  • the tag has no firing trigger attached

Verified further by trying and failing to build the opposite: GTM will not publish a conversion tag with an empty currency field, nor a GA4 event tag with an empty measurement ID. These are not faults you will ever find in a published container, because the container cannot contain them. If a user describes a symptom that would require one, the tag is either unpublished, paused, or was never saved.

So __awct absent means no conversion tag is live. It does not mean no tag exists, and it never justifies telling a user who built one that they have none. Every one of these is invisible from outside and only the API separates them (see the container access note in audit-playbook.md). This is also why a user insisting "the tag is right there, I'm looking at it" is usually telling the truth.

  • **Paused tags are invisible here, and __paused is a trap.** GTM omits a paused tag from the published container entirely rather than flagging it. Verified on two otherwise identical containers: the live one carries __awct, its conversion ID and its label; the paused one carries none of the three. **Do not read __paused:1 as evidence.** It appears in every container as part of GTM's internal tag-type registry, next to __tl, __tg and __ytl. It says a tag type exists in GTM, not that this container has a paused tag. An agent under test reached the right answer by misreading exactly this, which is luck rather than diagnosis and will produce a confident wrong answer on the next container. When a user believes a tag exists and you cannot find one, say no tag is live, name paused as a likely cause, and confirm through the API (see audit-playbook.md). Never present it as something you observed in gtm.js.
  • Grep whole file for AW-, conversionLabel, pixel IDs. Nothing matching = container has NO conversion tags regardless of what the page promises.

B.3 How existing detectors work

Wappalyzer/BuiltWith: regex DB over script srcs, HTML, JS globals, cookies. Tag Assistant: instruments live page, shows fired tags + hit params. Meta Ads Data Advisor (formerly Pixel Helper): checks fbq state + observed /tr requests. Headless equivalent: watch network + evaluate fbq.getState().

Raw lines 56 to 119
120### C. Dynamic checks (headless browser / Playwright)
122Page-load pass - record requests, match:
124| Vendor | Hit signature |
125|---|---|
126| GA4 | `google-analytics.com/g/collect`, `analytics.google.com/g/collect`, `region1.google-analytics.com/g/collect` (v=2&tid=G-...&en=<event>) - or /g/collect on first-party sGTM domain |
127| Google Ads | `googleads.g.doubleclick.net/pagead/viewthroughconversion/<id>/`, `googleadservices.com/pagead/conversion/<id>/`, `google.com/pagead/1p-conversion/` |
128| Meta | `facebook.com/tr` (id=<pixel>&ev=<event>; ev=Lead on submit) |
129| TikTok | `analytics.tiktok.com/api/v2/pixel` |
130| LinkedIn | `px.ads.linkedin.com/collect?pid=` |
131| Microsoft | `bat.bing.com/action/0?ti=` |
133Checks:
1341. **Consent gating**: load with no interaction - which hits fire pre-consent? Inspect gcs=/gcd=. Re-run after accepting CMP.
1352. **Runtime state**: `window.dataLayer` dump, `typeof google_tag_manager`, `fbq.getState()`, `typeof ttq`, `window.uetq`, `window.HubSpotFormsV4`.
1363. **Submit simulation** (staging/test data, flagged as test): attach §A listeners first (message events, wpcf7mailsent, jQuery hooks for submit_success/gform_confirmation_loaded), fill + submit, assert: (a) dataLayer event pushed, (b) conversion-class network request fired, (c) success UX classified (URL change vs inline swap). Diff window.dataLayer before/after.
1374. **Thank-you direct visit**: load /thank-you directly - do conversion tags fire on bare load? (false-conversion risk)
1385. **postMessage sniff**: inject `window.addEventListener('message', m => log(m.origin, m.data))` before interacting with embeds.

C. Dynamic checks (headless browser / Playwright)

Page-load pass - record requests, match:

VendorHit signature
GA4google-analytics.com/g/collect, analytics.google.com/g/collect, region1.google-analytics.com/g/collect (v=2&tid=G-...&en=<event>) - or /g/collect on first-party sGTM domain
Google Adsgoogleads.g.doubleclick.net/pagead/viewthroughconversion/<id>/, googleadservices.com/pagead/conversion/<id>/, google.com/pagead/1p-conversion/
Metafacebook.com/tr (id=<pixel>&ev=<event>; ev=Lead on submit)
TikTokanalytics.tiktok.com/api/v2/pixel
LinkedInpx.ads.linkedin.com/collect?pid=
Microsoftbat.bing.com/action/0?ti=

Checks:

  1. Consent gating: load with no interaction - which hits fire pre-consent? Inspect gcs=/gcd=. Re-run after accepting CMP.
  2. Runtime state: window.dataLayer dump, typeof google_tag_manager, fbq.getState(), typeof ttq, window.uetq, window.HubSpotFormsV4.
  3. Submit simulation (staging/test data, flagged as test): attach §A listeners first (message events, wpcf7mailsent, jQuery hooks for submit_success/gform_confirmation_loaded), fill + submit, assert: (a) dataLayer event pushed, (b) conversion-class network request fired, (c) success UX classified (URL change vs inline swap). Diff window.dataLayer before/after.
  4. Thank-you direct visit: load /thank-you directly - do conversion tags fire on bare load? (false-conversion risk)
  5. postMessage sniff: inject window.addEventListener('message', m => log(m.origin, m.data)) before interacting with embeds.
Raw lines 120 to 139
140### D. Codebase grep list
142Presence:
143```
144grep -rn "gtag(" "GTM-" "googletagmanager" "dataLayer" "fbq(" "ttq\." "_linkedin_partner_id\|licdn" "uetq\|bat.bing"
145grep -rn "@next/third-parties\|GoogleTagManager\|GoogleAnalytics\|sendGTMEvent\|sendGAEvent"
146grep -rn "react-gtm-module\|react-ga4\|nextjs-google-analytics"
147grep -rn "analytics.track\|segment\|posthog.capture\|plausible("
148```
149Conversion wiring (the usually-missing part):
150```
151grep -rn "send_to"
152grep -rn "'event', 'conversion'\|\"event\", \"conversion\""
153grep -rn "generate_lead\|form_submit\|formSubmit"
154grep -rn "onSubmit\|handleSubmit" # then READ each handler:
155# does the success branch (after await fetch) contain gtag/fbq/dataLayer.push?
156# red flag: fetch('/api/contact') → setSubmitted(true) with no tracking call
157grep -rn "router.push\|redirect(" -- **/contact* **/form*
158```
159SPA failure modes: GA4/GTM loaded once, no history-change tracking; next/script wrong strategy; tags in one layout only; thank-you client-side-navigated so initial-load Page View trigger never fires.
161Server-side:
162```
163grep -rn "graph.facebook.com" # Meta CAPI
164grep -rn "google-analytics.com/mp/collect\|api_secret" # GA4 Measurement Protocol
165grep -rn "googleads\|ConversionUploadService\|ClickConversion" # Ads offline conversions
166grep -rn "gclid\|wbraid\|gbraid\|fbclid\|msclkid\|li_fat_id" # click-id capture
167grep -rn "utm_source" -- **/*form* # hidden-field capture
168```
169Cross-check: dataLayer event names pushed in code must EXACTLY match predicate strings in the fetched gtm.js container - mismatch (`formSubmitted` in code vs `form_submit` in GTM) = complete silent failure.

D. Codebase grep list

Presence:

grep -rn "gtag("  "GTM-"  "googletagmanager"  "dataLayer"  "fbq("  "ttq\."  "_linkedin_partner_id\|licdn"  "uetq\|bat.bing"
grep -rn "@next/third-parties\|GoogleTagManager\|GoogleAnalytics\|sendGTMEvent\|sendGAEvent"
grep -rn "react-gtm-module\|react-ga4\|nextjs-google-analytics"
grep -rn "analytics.track\|segment\|posthog.capture\|plausible("

Conversion wiring (the usually-missing part):

grep -rn "send_to"
grep -rn "'event', 'conversion'\|\"event\", \"conversion\""
grep -rn "generate_lead\|form_submit\|formSubmit"
grep -rn "onSubmit\|handleSubmit"   # then READ each handler:
#   does the success branch (after await fetch) contain gtag/fbq/dataLayer.push?
#   red flag: fetch('/api/contact') → setSubmitted(true) with no tracking call
grep -rn "router.push\|redirect(" -- **/contact* **/form*

SPA failure modes: GA4/GTM loaded once, no history-change tracking; next/script wrong strategy; tags in one layout only; thank-you client-side-navigated so initial-load Page View trigger never fires.

Server-side:

grep -rn "graph.facebook.com"                                   # Meta CAPI
grep -rn "google-analytics.com/mp/collect\|api_secret"          # GA4 Measurement Protocol
grep -rn "googleads\|ConversionUploadService\|ClickConversion"  # Ads offline conversions
grep -rn "gclid\|wbraid\|gbraid\|fbclid\|msclkid\|li_fat_id"    # click-id capture
grep -rn "utm_source" -- **/*form*                              # hidden-field capture

Cross-check: dataLayer event names pushed in code must EXACTLY match predicate strings in the fetched gtm.js container - mismatch (formSubmitted in code vs form_submit in GTM) = complete silent failure.

Raw lines 140 to 170
171## 2. Shipped detectors: tested snippet plus importable GTM recipe
173Build a merged container for any of these against any destination:
175```
176python3 ../scripts/build_recipe.py --tool <slug> --send <google-ads|ga4|meta|linkedin|tiktok|microsoft> [ids] -o import-me.json
177```
179| Slug | Tool | Moment | dataLayer event |
180|---|---|---|---|
181| `acuity-scheduling` | Acuity Scheduling | meeting booked | `acuity_appointment_scheduled` |
182| `cal-com` | Cal.com | meeting booked | `calcom_booking_successful` |
183| `calendly` | Calendly | meeting booked | `calendly_event_scheduled` |
184| `contact-form-7` | Contact Form 7 | form submission | `contact_form_7_submitted` |
185| `divi-forms` | Divi Forms | form submission | `divi_form_submitted` |
186| `elementor-forms` | Elementor Forms | form submission | `elementor_form_submitted` |
187| `fluent-forms` | Fluent Forms | form submission | `fluent_forms_submitted` |
188| `formidable-forms` | Formidable Forms | form submission | `formidable_forms_submitted` |
189| `forminator` | Forminator | form submission | `forminator_form_submitted` |
190| `framer-forms` | Framer Forms | form submission | `framer_form_submitted` |
191| `gravity-forms` | Gravity Forms | form submission | `gravity_form_submitted` |
192| `hubspot-forms` | HubSpot Forms | form submission | `hubspot_form_submitted` |
193| `jotform` | Jotform | form submission | `jotform_form_submitted` |
194| `ninja-forms` | Ninja Forms | form submission | `ninja_forms_submitted` |
195| `squarespace-forms` | Squarespace Forms | form submission | `squarespace_form_submitted` |
196| `tally` | Tally | form submission | `tally_form_submitted` |
197| `typeform` | Typeform | form submission | `typeform_form_submitted` |
198| `webflow-forms` | Webflow Forms | form submission | `webflow_form_submitted` |
199| `wix-forms` | Wix Forms | form submission | `wix_form_submitted` |
200| `wix-scheduling` | Wix Bookings | booking made | `wix_appointment_scheduled` |
201| `wpforms` | WPForms | form submission | `wpforms_form_submitted` |
202| `ws-form` | WS Form | form submission | `ws_form_submitted` |
204**Detection confidence.** Most of these fire on the tool's own success event or on a
205confirmed success state, which is exact. `acuity-scheduling` fires only when the snippet
206Acuity runs after a booking reports it, so it needs that snippet in Acuity too, and it
207counts package and gift certificate purchases unless the trigger filters `acuity_type` to
208`appointment`. `cal-com` also counts bookings still waiting for
209the host to confirm, marked `booking_status` `PENDING`. `hubspot-forms` also counts
210submissions HubSpot's bot filter files under Spam. `framer-forms` fires when Framer's own form
211endpoint answers its request with success. `squarespace-forms` fires only once Squarespace
212confirms the save, through its own success markers or its save request, never on the
213submit alone. `wix-forms` fires when Wix's own thank-you
214message appears inside the form, which Wix shows only after the entry is saved. It covers
215Wix's current forms with an inline thank-you message, not older Wix contact forms or forms
216set to go to another page after submitting.

2. Shipped detectors: tested snippet plus importable GTM recipe

Build a merged container for any of these against any destination:

python3 ../scripts/build_recipe.py --tool <slug> --send <google-ads|ga4|meta|linkedin|tiktok|microsoft> [ids] -o import-me.json
SlugToolMomentdataLayer event
acuity-schedulingAcuity Schedulingmeeting bookedacuity_appointment_scheduled
cal-comCal.commeeting bookedcalcom_booking_successful
calendlyCalendlymeeting bookedcalendly_event_scheduled
contact-form-7Contact Form 7form submissioncontact_form_7_submitted
divi-formsDivi Formsform submissiondivi_form_submitted
elementor-formsElementor Formsform submissionelementor_form_submitted
fluent-formsFluent Formsform submissionfluent_forms_submitted
formidable-formsFormidable Formsform submissionformidable_forms_submitted
forminatorForminatorform submissionforminator_form_submitted
framer-formsFramer Formsform submissionframer_form_submitted
gravity-formsGravity Formsform submissiongravity_form_submitted
hubspot-formsHubSpot Formsform submissionhubspot_form_submitted
jotformJotformform submissionjotform_form_submitted
ninja-formsNinja Formsform submissionninja_forms_submitted
squarespace-formsSquarespace Formsform submissionsquarespace_form_submitted
tallyTallyform submissiontally_form_submitted
typeformTypeformform submissiontypeform_form_submitted
webflow-formsWebflow Formsform submissionwebflow_form_submitted
wix-formsWix Formsform submissionwix_form_submitted
wix-schedulingWix Bookingsbooking madewix_appointment_scheduled
wpformsWPFormsform submissionwpforms_form_submitted
ws-formWS Formform submissionws_form_submitted

Detection confidence. Most of these fire on the tool's own success event or on a confirmed success state, which is exact. acuity-scheduling fires only when the snippet Acuity runs after a booking reports it, so it needs that snippet in Acuity too, and it counts package and gift certificate purchases unless the trigger filters acuity_type to appointment. cal-com also counts bookings still waiting for the host to confirm, marked booking_status PENDING. hubspot-forms also counts submissions HubSpot's bot filter files under Spam. framer-forms fires when Framer's own form endpoint answers its request with success. squarespace-forms fires only once Squarespace confirms the save, through its own success markers or its save request, never on the submit alone. wix-forms fires when Wix's own thank-you message appears inside the form, which Wix shows only after the entry is saved. It covers Wix's current forms with an inline thank-you message, not older Wix contact forms or forms set to go to another page after submitting.

Raw lines 171 to 217
Runs code python3 ../scripts/build_recipe.py :176
218## Universal patterns
220These are not fallbacks of last resort. Two of them are the correct primary answer for
221whole categories of business.
223| Slug | Covers | Notes |
224|---|---|---|
225| `phone-click` | Click on a `tel:` link | **The most under-tracked conversion there is.** For a plumber, a dentist, a roofer or a lawyer, the conversion *is* the phone call and no form exists. Counts intent, not answered calls; use a call tracking number if you need answered-call accuracy |
226| `thank-you-page` | Arrival on a confirmation page | Edit the path list at the top. Handles single-page apps by watching history changes, which matters because a React or Next site changes URL without a pageview and a thank-you page trigger silently counts zero |
227| `generic-ajax-form` | Inline success after an AJAX submit | The long tail of unknown form tools. Refuses to fire on submit alone: it waits for a success signal and bails if a visible validation error appears. Weaker than a named detector, so always prefer one when it exists |
228| `file-download` | Click on a document or media link | Price lists, brochures, spec sheets. GA4 already collects this natively; use it when you need the signal in Google Ads or Meta, which do not |

Universal patterns

These are not fallbacks of last resort. Two of them are the correct primary answer for whole categories of business.

SlugCoversNotes
phone-clickClick on a tel: linkThe most under-tracked conversion there is. For a plumber, a dentist, a roofer or a lawyer, the conversion is the phone call and no form exists. Counts intent, not answered calls; use a call tracking number if you need answered-call accuracy
thank-you-pageArrival on a confirmation pageEdit the path list at the top. Handles single-page apps by watching history changes, which matters because a React or Next site changes URL without a pageview and a thank-you page trigger silently counts zero
generic-ajax-formInline success after an AJAX submitThe long tail of unknown form tools. Refuses to fire on submit alone: it waits for a success signal and bails if a visible validation error appears. Weaker than a named detector, so always prefer one when it exists
file-downloadClick on a document or media linkPrice lists, brochures, spec sheets. GA4 already collects this natively; use it when you need the signal in Google Ads or Meta, which do not
Raw lines 218 to 229
230## 3. The long tail
232**Never tell someone their tool is unsupported.** Every conversion moment falls into one of
233the install shapes below, and the universal patterns cover tools with no named detector.
234Some of these tools also have native server-side integrations from their own platform, and
235managed tools listed there may cover them as built-in triggers; both live in `server-side.md`. What
236"no snippet" means is only that this skill's free browser-side detector does not exist yet.
238**Shape 1, page listener** (28): ActiveCampaign, Kit, AWeber, Mailchimp, Klaviyo,
239Marketo, Pipedrive, Duda, Avada, Beaver Builder, GoHighLevel, Unbounce,
240Instapage, Leadpages, Paperform, Fillout, Formstack, Zoho Forms, HelloBar, OptinMonster,
241ConvertBox, Thrive Leads, HubSpot Meetings, Chili Piper, Intercom, Crisp,
242Tawk.to, Drift.
244**Shape 2, WordPress but not a form submit** (6): LearnDash, LearnPress, Tutor LMS, Paid
245Memberships Pro, Ultimate Member, Easy Digital Downloads.
247**Shape 3, two-part install** (2): Pardot, Salesforce Web-to-Lead.
249**Shape 4, conversion on someone else's domain** (5): Kajabi, Teachable, Thinkific,
250ThriveCart, ClickFunnels checkout.

3. The long tail

Never tell someone their tool is unsupported. Every conversion moment falls into one of the install shapes below, and the universal patterns cover tools with no named detector. Some of these tools also have native server-side integrations from their own platform, and managed tools listed there may cover them as built-in triggers; both live in server-side.md. What "no snippet" means is only that this skill's free browser-side detector does not exist yet.

Shape 1, page listener (28): ActiveCampaign, Kit, AWeber, Mailchimp, Klaviyo, Marketo, Pipedrive, Duda, Avada, Beaver Builder, GoHighLevel, Unbounce, Instapage, Leadpages, Paperform, Fillout, Formstack, Zoho Forms, HelloBar, OptinMonster, ConvertBox, Thrive Leads, HubSpot Meetings, Chili Piper, Intercom, Crisp, Tawk.to, Drift.

Shape 2, WordPress but not a form submit (6): LearnDash, LearnPress, Tutor LMS, Paid Memberships Pro, Ultimate Member, Easy Digital Downloads.

Shape 3, two-part install (2): Pardot, Salesforce Web-to-Lead.

Shape 4, conversion on someone else's domain (5): Kajabi, Teachable, Thinkific, ThriveCart, ClickFunnels checkout.

Raw lines 230 to 252
253## The install shapes
255Work out which one applies, then act.
257**Shape 1. A page listener works.** The tool renders in the page and emits something
258observable: its own JS event, a success callback, or a success element appearing. Use
259`generic-ajax-form`, or write a named detector following any shipped snippet.
261**Shape 2. Same domain, but the moment is not a form submit.** An enrolment, a membership
262signup, a course completion. There is usually no submit event to hook. Use
263`thank-you-page` against the post-enrolment URL, or a platform hook if one exists.
265**Shape 3. Half the code has to go inside the vendor's own tool.** Classic Pardot forms
266are iframes served from a Salesforce domain, so nothing on the parent page can see inside
267them. The fix is code pasted into the vendor's own thank-you or completion field that
268messages out to the parent page, plus a listener that catches it. `acuity-scheduling` is
269the shipped example (`acuity-scheduling-field.html` goes in Acuity's Custom Conversion
270Tracking field, `acuity-scheduling.js` catches its message). Walk the user through
271the vendor screen; there is no snippet-only answer.
273**Shape 3b. The moment only the backend knows.** A SaaS signup, an account created, a
274lead an API received. No browser event exists at all, so no snippet applies and
275no GTM recipe can help. The backend has to report it (direct platform APIs or a managed
276tool that pairs a backend event with the browser's click IDs, see `server-side.md`), and
277the click ID has to be carried across from the browser session separately. Ecommerce
278purchases are not this shape; they route through `ecommerce-platforms.md`.
280**Shape 4. The conversion happens on a domain the user does not own.** Kajabi, Teachable,
281Thinkific, ThriveCart, hosted checkouts. Several provide a checkout tracking code field,
282so a pixel can fire. **What cannot cross is the click ID**, because it lives in a cookie
283on the user's own domain and is unreadable from the vendor's. The sale records with no
284click attached, so the ad platform cannot attribute it.
286Be straight about this. It is not a configuration mistake and no snippet fixes it. The
287options are cross-domain linking where the vendor supports it, or server-side capture that
288carries the identifier server to server (`server-side.md`). This is the strongest
289genuine case for server-side tracking in this skill, so it is also the one where any
290recommendation must be most carefully earned rather than assumed.

The install shapes

Work out which one applies, then act.

Shape 1. A page listener works. The tool renders in the page and emits something observable: its own JS event, a success callback, or a success element appearing. Use generic-ajax-form, or write a named detector following any shipped snippet.

Shape 2. Same domain, but the moment is not a form submit. An enrolment, a membership signup, a course completion. There is usually no submit event to hook. Use thank-you-page against the post-enrolment URL, or a platform hook if one exists.

Shape 3. Half the code has to go inside the vendor's own tool. Classic Pardot forms are iframes served from a Salesforce domain, so nothing on the parent page can see inside them. The fix is code pasted into the vendor's own thank-you or completion field that messages out to the parent page, plus a listener that catches it. acuity-scheduling is the shipped example (acuity-scheduling-field.html goes in Acuity's Custom Conversion Tracking field, acuity-scheduling.js catches its message). Walk the user through the vendor screen; there is no snippet-only answer.

Shape 3b. The moment only the backend knows. A SaaS signup, an account created, a lead an API received. No browser event exists at all, so no snippet applies and no GTM recipe can help. The backend has to report it (direct platform APIs or a managed tool that pairs a backend event with the browser's click IDs, see server-side.md), and the click ID has to be carried across from the browser session separately. Ecommerce purchases are not this shape; they route through ecommerce-platforms.md.

Shape 4. The conversion happens on a domain the user does not own. Kajabi, Teachable, Thinkific, ThriveCart, hosted checkouts. Several provide a checkout tracking code field, so a pixel can fire. What cannot cross is the click ID, because it lives in a cookie on the user's own domain and is unreadable from the vendor's. The sale records with no click attached, so the ad platform cannot attribute it.

Be straight about this. It is not a configuration mistake and no snippet fixes it. The options are cross-domain linking where the vendor supports it, or server-side capture that carries the identifier server to server (server-side.md). This is the strongest genuine case for server-side tracking in this skill, so it is also the one where any recommendation must be most carefully earned rather than assumed.

Raw lines 253 to 291
292## When nothing here fits
2941. **Ask what happens after submit.** Redirect to a new URL, inline message, or nothing
295 visible? That answer alone picks the shape.
2962. **Look at the page.** section A above covers the four
297 submit patterns and how to tell them apart.
2983. **Check whether the platform already does it.** Native integrations are free and
299 maintained by the platform where they cover the moment and destination (`server-side.md`).
300 Lead with the native path, then present the other options alongside it.
3014. **Only then** reach for `generic-ajax-form` or `thank-you-page`.
303If you build a working detector for a tool not listed here, it is worth contributing back.

When nothing here fits

  1. Ask what happens after submit. Redirect to a new URL, inline message, or nothing visible? That answer alone picks the shape.
  2. Look at the page. section A above covers the four submit patterns and how to tell them apart.
  3. Check whether the platform already does it. Native integrations are free and maintained by the platform where they cover the moment and destination (server-side.md). Lead with the native path, then present the other options alongside it.
  4. Only then reach for generic-ajax-form or thank-you-page.

If you build a working detector for a tool not listed here, it is worth contributing back.

Raw lines 292 to 305
306## 4. Ecommerce moments
308The inference problem above mostly does not exist for purchases, because an order record
309does. Use the platform's own confirmed-order surface and never rebuild the inference:
311- **Shopify:** `checkout_completed` via a web pixel, or a native channel doing it for you. The fire-on-confirmed-success rule is satisfied by construction.
312- **WooCommerce:** the server-side order hooks (`woocommerce_payment_complete`, `woocommerce_thankyou`), with a tracked flag in order meta so refreshes never double count.
313- **Easy Digital Downloads:** `edd_update_payment_status` for the order lifecycle.
314- **Hosted checkouts on a vendor's domain** (Kajabi, Teachable, ThriveCart and similar) are Shape 4 above: a pixel may fire, but the click ID cannot cross domains, which is a structural limit, not a configuration mistake.
315- **Custom stores:** the order-confirmation webhook or handler is the moment. Nothing needs inferring.
317Full platform detail, the deprecation calendar, and the shared ecommerce mechanics (dedup, value correctness, feeds, refunds) are in `ecommerce-platforms.md`.
318Key sources: contactform7.com/dom-events; docs.gravityforms.com gform_confirmation_loaded; developer.calendly.com parent-window notifications; typeform.com/developers/embed/callbacks; developers.hubspot.com global form events (v4) + legacydocs (v2); developers.google.com/tag-platform/devguides/existing; simoahava.com container snippet internals; conversiontracking.io Jotform/Typeform/iframe guides; analyticsmania.com per-platform GTM guides; trackingchef.com bulletproof form tracking series; dumbdata.co listeners; freak.marketing Squarespace; framer.com UTM/Ads IDs help; stape.io custom loader docs; wappalyzer source.

4. Ecommerce moments

The inference problem above mostly does not exist for purchases, because an order record does. Use the platform's own confirmed-order surface and never rebuild the inference:

  • Shopify: checkout_completed via a web pixel, or a native channel doing it for you. The fire-on-confirmed-success rule is satisfied by construction.
  • WooCommerce: the server-side order hooks (woocommerce_payment_complete, woocommerce_thankyou), with a tracked flag in order meta so refreshes never double count.
  • Easy Digital Downloads: edd_update_payment_status for the order lifecycle.
  • Hosted checkouts on a vendor's domain (Kajabi, Teachable, ThriveCart and similar) are Shape 4 above: a pixel may fire, but the click ID cannot cross domains, which is a structural limit, not a configuration mistake.
  • Custom stores: the order-confirmation webhook or handler is the moment. Nothing needs inferring.

Full platform detail, the deprecation calendar, and the shared ecommerce mechanics (dedup, value correctness, feeds, refunds) are in ecommerce-platforms.md. Key sources: contactform7.com/dom-events; docs.gravityforms.com gform_confirmation_loaded; developer.calendly.com parent-window notifications; typeform.com/developers/embed/callbacks; developers.hubspot.com global form events (v4) + legacydocs (v2); developers.google.com/tag-platform/devguides/existing; simoahava.com container snippet internals; conversiontracking.io Jotform/Typeform/iframe guides; analyticsmania.com per-platform GTM guides; trackingchef.com bulletproof form tracking series; dumbdata.co listeners; freak.marketing Squarespace; framer.com UTM/Ads IDs help; stape.io custom loader docs; wappalyzer source.

Raw lines 306 to 318