Conversion tracking for Google Ads, Facebook, and LinkedIn
Nutritionist Pro reports three things to the advertising platforms: a purchase on the shop, a free trial sign-up, and two shop clicks (Add to Cart and Checkout). All of it runs through one Google Tag Manager (GTM) container that the Nexgen apps load in production. The apps themselves contain no Google Ads, Facebook, or LinkedIn code. They push a small set of events into the browser's dataLayer, and the tags inside the container turn those events into conversions for each platform.
This page is the reference for the whole chain: the container, the events the apps push, the three platforms' settings, the Content Security Policy (CSP) that has to allow each vendor, how to verify it works, and how to change it. The Google Ads account itself is covered in Google Ads.
How the pieces fit together
There are three layers, and a conversion needs all three to agree:
- The app pushes events. The Laravel backend in the
nexgen_v3repo renders a few lines of JavaScript that push named events intowindow.dataLayer. It does this whether or not GTM is loaded, so the pushes can be checked locally and in tests. - The GTM container turns events into tags. Triggers in the container listen for those event names (and for two button clicks) and fire the Google Ads, Facebook, and LinkedIn tags. The container is published and edited in the Tag Manager console, not in the code.
- Each platform counts the conversion. Google Ads, Meta Events Manager, and LinkedIn Campaign Manager each define their own conversion actions; the tags reference them by id or label.
A change to any layer can silently break the others. The most common failure is the event name in the code and the trigger in the container drifting apart; see Troubleshooting.
The GTM container
The apps load container GTM-WXHXW22M, named "Nutritionist Pro app", in the Tag Manager account Axxya Systems (account id 6344786169, container id 266749545). Open it at tagmanager.google.com. If the container is missing from the list, you are signed in as a Google user that is not on the Axxya Systems Tag Manager account.
The previous container, GTM-PSXQBSK, sits in a Tag Manager account that nobody at Axxya can access. Its tags were rebuilt in the new container in October 2026 from a container export kept in the nexgen_v3 repo at docs/gtm-app-container-ads-conversions.json. That file is the record of what the container should hold; keep it in step with the console (see Keeping the import file current).
Version 2 of the container, published on October 10, 2026, holds:
- 15 tags: the Google tag, the Conversion Linker, three Google Ads conversion tags, the Facebook pixel base tag plus four Facebook event tags, and the LinkedIn Insight Tag plus four LinkedIn conversion tags.
- 4 triggers: two custom-event triggers (
transactionandsign_up_free_trial) and two click triggers (Click Text matchesadd to cartand^checkout$, both ignoring case). - 3 data layer variables:
ecommerce.purchase.actionField.id,.revenue, and.currencyCode, all reading data layer version 2. - The built-in variables Page URL, Event, and Click Text.
No GA4 event tags were added to the container. GA4 page views come from the gtag.js snippet the app renders alongside the container (see the next section), and adding GA4 purchase events here would double count against the GA4 import actions already in Google Ads.
Where the container loads
Everything is driven by nexgen/backend/config/analytics.php in the nexgen_v3 repo, which reads three environment variables:
GTM_ENABLED(defaultfalse).GTM_CONTAINER_ID(default empty).GA4_MEASUREMENT_ID(default empty).
The tag markup renders only when GTM_ENABLED is true and the container id is non-empty. The Copilot manifests set all three in their prod sections only (copilot/nexgen-backend/manifest.yml and copilot/nexgen-free-backend/manifest.yml), so staging, test, local development, CI, and the E2E suites render no tags and send no hits. The data layer pushes still render everywhere, which is what makes them testable.
The two Blade partials resources/views/partials/gtm-head.blade.php and gtm-body.blade.php hold the markup. Four layouts include them:
front/guest.blade.php: the public shop pages and the thank-you page.front/cart/master.blade.php: the cart and checkout.front/manage/master.blade.php: the customer's manage pages.auth/register.blade.php: the trial registration page,trial.nutritionistpro.com/register-trial, which is a standalone page with no shared layout.
The Angular app at app.nutritionistpro.com does not load the container. Only the Laravel-rendered pages do.
nexgen and nexgen-free run the same source and load the same container, but they report into separate GA4 properties so trial traffic stays out of the paid property: nexgen uses G-BT7330TRH9 and nexgen-free uses G-58CZBD0BMZ. GA4 is loaded by a gtag.js snippet in the head partial, not by the container.
Google changes the content of gtm.js and gtag.js between releases, so a pinned SRI hash stops matching and the browser blocks the script. That silently disabled tracking on the shop once already. The comment in gtm-head.blade.php says the same.
The events the apps push
The container listens for two custom events and two clicks. This section is the contract between the code and the container.
transaction (purchase)
After a successful checkout the customer lands on /shop/thank-you, rendered by HomeController::thankYou with purchaseAnalyticsEvents() building the payload. The page pushes one transaction event per Chargebee invoice in the Universal Analytics enhanced-ecommerce shape:
{
"event": "transaction",
"ecommerce": {
"purchase": {
"actionField": {
"id": "30227392",
"revenue": 499,
"tax": 0,
"shipping": 0,
"currencyCode": "USD"
},
"products": [
{ "id": "9100001", "name": "Label Module (1 year)", "price": 499, "quantity": 1 }
]
}
}
}
The field names matter. The container's three data layer variables read ecommerce.purchase.actionField.id, .revenue, and .currencyCode, and the Google Ads purchase tag uses them as the transaction id, conversion value, and currency. The Facebook Purchase tag reads the same three. products is carried for completeness; nothing in the current container reads it. Each push is preceded by window.dataLayer.push({ ecommerce: null }) so fields from an earlier event cannot bleed into the next one.
Three rules keep the event honest:
- Only the order just bought is reported. The checkout stores the Chargebee subscription ids of the purchase in the session, and the thank-you page selects orders by those ids. It does not filter by order status, because a fresh Chargebee purchase is stored with status
Aand only becomesApprovedlater, at login. An earlier version filtered byApprovedand reported the customer's old orders instead of the new one. - A refresh does not fire twice. The subscription ids are pulled from the session (read once and forgotten), so reloading the thank-you page renders an empty list and pushes nothing.
- A direct visit pushes nothing. Opening
/shop/thank-youfrom a bookmark has no ids in the session, so no event is rendered.
The shape is pinned by tests/Feature/ThankYouPurchaseEventTest.php (PHPUnit) and by the N-SHOP-BUY-1 test in e2e/nexgen/shop-purchase.spec.ts, which reads window.dataLayer on the thank-you page after a real test-site purchase.
sign_up_free_trial (trial registration)
A successful trial registration redirects back to GET /register-trial with a register_success flash in the session, and the page shows a green .alert-success box. When that flash is present, auth/register.blade.php renders one push right after the GTM body partial:
window.dataLayer.push({ event: 'sign_up_free_trial' });
The push has to sit before the alert markup because the view forgets the flash inline while rendering the alert. The flash is only set on the real sign-up success path in RegisteredUserController, so duplicate-email and validation failures never fire it, and a plain visit or a reload of the page does not either.
Pinned by the sign_up_free_trial test in tests/Feature/TrialRegistrationTest.php and by fillAndSubmitTrialForm in e2e/helpers/register-user.ts, which treats a success alert without the push as a failed registration.
Add to Cart and Checkout clicks
The app pushes nothing for these. The container's two click triggers match the visible text of the clicked element: add to cart anywhere in the text, and exactly checkout, both case-insensitive. They fire on the shop's product buttons and the cart's Checkout button. Renaming either button breaks these conversions, so check the container when changing shop button labels.
Google Ads
The Google Ads account is Axxya Systems, customer id 926-442-1561 (see Google Ads). Its conversion id is AW-957275630; the Google tag for that id and the Conversion Linker fire on every page the container loads on. Three conversion tags map to three conversion actions in the account:
- Purchase GTM, label
FDBrCM_S6d8DEO67u8gD, fires ontransactionwith the invoice id as transaction id,revenueas value, andcurrencyCodeas currency. A primary action. - Free Trial Signup - GTM, label
YfXPCNLS6d8DEO67u8gD, fires onsign_up_free_trial. A primary action. - Add to cart - GTM, label
z9WOCNXS6d8DEO67u8gD, fires on the Add to Cart click. A secondary action, so it is observed but not counted in the Conversions column.
All three have the Conversion Linker enabled, which writes the _gcl_* cookies that connect a conversion back to the ad click. Ads land on nutritionistpro.com (the WordPress site, with its own container GTM-TTMXW7G2) and the purchase or sign-up completes on app.nutritionistpro.com or trial.nutritionistpro.com. Those are all the same registrable domain, so the linker cookie carries over without cross-domain setup.
Check the actions at Goals > Conversions > Summary in the Ads console. A tag that has not fired recently shows Needs attention; it flips back to Active after the first conversion comes in.
The account also holds three "GA4 (web) purchase" import actions marked primary. GA4 receives a purchase for the same sale from the GA4 property's own ecommerce reporting, so a sale can be counted once by Purchase GTM and again by the GA4 imports. Decide which of the two should stay primary; as of October 2026 both are.
Facebook pixel
The Facebook (Meta) pixel id is 1147726086025086. The container holds it as five Custom HTML tags rather than the community pixel template, because a GTM container export cannot carry community templates and the container was rebuilt from an export:
- Facebook Pixel - Base fires on all pages: loads
connect.facebook.net/en_US/fbevents.js, callsfbq('init', ...)andfbq('track', 'PageView'). - Purchase fires on
transactionwithvalue,currency, andorder_idfrom the three data layer variables. - CompleteRegistration fires on
sign_up_free_trial. - AddToCart fires on the Add to Cart click.
- InitiateCheckout fires on the Checkout click.
The four event tags use the base tag as a setup tag with "stop on setup failure", so an event is never sent before the pixel is initialized. Events appear in Meta Events Manager under that pixel id; a Meta account with access to the Axxya ad account is needed to see them.
LinkedIn conversions
The LinkedIn Insight Tag partner id is 3890068, loaded on all pages by the built-in LinkedIn Insight tag type. Four Custom HTML tags call window.lintrk('track', { conversion_id: ... }) with the base tag as their setup tag:
- Purchase, conversion id
9481020, ontransaction. - Add to cart, conversion id
9481028, on the Add to Cart click. - Checkout, conversion id
9481036, on the Checkout click. - Free trial signup, conversion id
9481044, onsign_up_free_trial.
The conversion ids are defined in LinkedIn Campaign Manager under the Axxya account (Analyze > Conversion tracking). A new LinkedIn conversion means creating it there first, then adding a tag here with its id.
Content Security Policy
The Laravel middleware SecurityHeaders (app/Http/Middleware/SecurityHeaders.php) sets a CSP header, and it is applied only to the route group in routes/auth.php: the login page and the trial registration page. The shop, cart, manage, and thank-you pages carry no CSP at all. So the trial registration page is the one place where a vendor's script can be blocked, and it is the page that fires the trial sign-up conversion for all three platforms.
The CSP allows these vendor hosts:
script-src:www.googletagmanager.com,*.google-analytics.com,www.googleadservices.com,www.google.com,connect.facebook.net,snap.licdn.com.connect-src:*.google-analytics.com,*.analytics.google.com,www.googletagmanager.com,www.googleadservices.com,googleads.g.doubleclick.net,pagead2.googlesyndication.com,www.google.com,ad.doubleclick.net,www.facebook.com,px.ads.linkedin.com,px4.ads.linkedin.com.frame-src:www.googletagmanager.com(plus the Turnstile host).img-srcis*, so tracking pixels are never blocked.
tests/Feature/SecurityHeadersAdsCspTest.php pins those hosts. The middleware is skipped in the testing environment, so that test swaps the app environment to local for one direct call to the middleware.
A new tag that loads a script from a new host runs fine on the shop and fails silently on the trial registration page until SecurityHeaders allows the host in both script-src and connect-src. Add the hosts, extend the CSP test, deploy, then publish the container change. The symptom of a missing host is described under Troubleshooting.
Verifying that it works
Start in the browser on a production page. The data layer pushes and the container both load only in production, so there is nothing to see on staging.
-
Check the container and the vendor scripts loaded. On
https://trial.nutritionistpro.com/register-trialopen the developer console and run:performance.getEntriesByType('resource').map(e => e.name).filter(n => /googletagmanager|googleads|facebook|licdn|linkedin/.test(n));typeof window.fbq && typeof window.fbq.callMethod;typeof window.lintrk;The first list should include
gtm.js,googleads.g.doubleclick.net/pagead/viewthroughconversion/957275630/,connect.facebook.net/en_US/fbevents.js, andsnap.licdn.com/li.lms-analytics/insight.min.js. The twotypeofchecks should both printfunction. Run the same checks onhttps://app.nutritionistpro.com/shopto compare a page with no CSP. -
Check the events in GTM Preview. In the Tag Manager console click Preview, enter the page URL, and complete the action. Tag Assistant lists each data layer event and the tags that fired on it. A trial sign-up should show the
sign_up_free_trialevent firing the Free Trial Signup Ads tag, the Facebook CompleteRegistration tag, and the LinkedIn Free trial signup tag. A purchase showstransactionfiring the Purchase tags. Use a real card on the live shop only when a refund is acceptable; see Refunds. -
Check the data layer directly. On the thank-you page after a purchase, or the registration page after a sign-up, run
window.dataLayer.filter(e => e.event === 'transaction' || e.event === 'sign_up_free_trial')in the console. The transaction entry should carryecommerce.purchase.actionFieldwith the invoice id, revenue, and currency. -
Check the platforms. Google Ads shows the action's status at Goals > Conversions > Summary and the counts a day later. Meta Events Manager and LinkedIn Campaign Manager show received events within minutes.
Reading the live container without the console
The served container is plain JavaScript, so the exact triggers and tags in production can be read without Tag Manager access:
curl -s 'https://www.googletagmanager.com/gtm.js?id=GTM-WXHXW22M' | grep -o '"arg1":"[^"]*"' | sort -u
That prints the trigger values (transaction, sign_up_free_trial, add to cart, ^checkout$). The full "resource":{...} JSON inside the file holds macros (variables), predicates (trigger conditions), tags, and rules. This is how the old container's contract was recovered when nobody could open it.
Automated coverage
ThankYouPurchaseEventTestandTrialRegistrationTest(PHPUnit) pin the two event shapes and the conditions under which they render.SecurityHeadersAdsCspTestpins the CSP hosts.- The N-SHOP-BUY-1 E2E test asserts exactly one
transactionentry on the thank-you page after a Chargebee test-site purchase, andfillAndSubmitTrialFormasserts thesign_up_free_trialentry on every E2E trial registration. Both run locally only (localOnlyIgnoreinplaywright.config.ts).
None of these prove that the container's triggers match; only GTM Preview or reading the served gtm.js does that.
Changing the container
Edit tags, triggers, and variables in the Tag Manager console, then Submit and Publish a new version with a short version name and description. Changes take effect for new page loads within minutes; nothing needs deploying. Before publishing, run Preview against the production page the change affects.
Things that need a code change as well as a container change:
- A new event name or a new data layer field: the Blade view (and its tests) must push it.
- A new vendor script host, or a new host an existing vendor starts using:
SecurityHeadersand its test. - Switching containers:
GTM_CONTAINER_IDin both prod manifests, then a prod deploy ofnexgen-backendandnexgen-free-backend.
Keeping the import file current
docs/gtm-app-container-ads-conversions.json in nexgen_v3 is the record of the container. After publishing a container change, export the new version (Admin > Export Container, pick the published version) and replace the file in a pull request, so the next rebuild starts from the live state rather than from October 2026.
Rebuilding the container from the file
If the container ever has to be recreated (for example in a new Tag Manager account):
- Create an empty web container in Tag Manager and note its public id.
- Admin > Import Container, choose the JSON file, Existing workspace, Merge. The preview should list 15 tags, 4 triggers, and 3 variables as Added. Importing the same file a second time into a workspace that already holds it shows only the three built-in variables as Deleted; cancel, there is nothing to add.
- Submit and Publish.
- Change
GTM_CONTAINER_IDin both prod manifests to the new public id and deploy both backends. - Run the checks under Verifying that it works.
The exported file carries accountId and containerId of 0 and the public id of the container it was exported from. Tag Manager ignores those on import.
Troubleshooting
- A conversion action shows zero and Needs attention. Compare the event name the page pushes (
window.dataLayerin the console) with the trigger in the container (GTM Preview, or thearg1values from the servedgtm.js). In August 2026 purchase conversions were zero because the code pushed a GA4purchaseevent while the container listened fortransaction;gtag.jsignores GTM-style pushes, so the event reached nothing while looking right in the page source. - A conversion stopped after a URL or host change. Old containers used page-URL filters on triggers. The Free Trial Signup trigger in
GTM-PSXQBSKonly matchednexgentrial.nutritionistpro.com/auth/register, the V2 host, which is why trial conversions went to zero when V3 took over the trial site. The current container uses custom events with no URL filters, so this cannot recur unless a URL filter is added. - The vendor script loads but nothing fires on the trial page. This is the CSP. The script appears in
performance.getEntriesByType('resource')buttypeof window.lintrkortypeof fbq.callMethodisundefined, because the inline stub exists and the real script was refused. Compare with the shop page in the same browser: if it works there, the CSP is the cause, not an ad blocker. Fix inSecurityHeaders. - Everything is undefined on every page. An ad blocker or privacy extension in the browser. Test in a clean profile.
- A purchase is counted twice in Google Ads. The Purchase GTM action and the GA4 purchase import actions are both primary; see the note under Google Ads.
- An E2E purchase test reports a Chargebee cleanup error. The real failure may be earlier in the checkout (for example the Chargebee test-site gateway refusing the card). The N-SHOP-BUY-1 test now reports both; check Mailpit for a "Payment Failed" email and the page screenshot before assuming the thank-you page is at fault.
Known gaps
- Renewals happen through Chargebee webhooks with no page view, so they are never reported as conversions.
- Extra seats, upgrades, extensions, and reactivations finish on the manage pages or through Ajax calls, not on the thank-you page, so they are not reported either.
- The Angular app does not load the container, so nothing a logged-in customer does inside the app is tracked.
- V3 has no cookie banner or Consent Mode.
- The container has no GA4 event tags. GA4 conversions for the sale come from the GA4 property's own ecommerce reporting, not from this container.
History
- 2023 to July 2026: the apps loaded
GTM-PSXQBSK. The Google Ads conversions chart showed 24 to 39 conversions a month, all of them trial sign-ups from the V2 trial host. Purchases were never counted in 2026 because the V3 thank-you page pushed events the container did not listen for. - August 2026: the V2 trial host was retired and the trial trigger's URL filter stopped matching, so the chart went to zero.
- October 2026 (issue-tracking #691, pull requests #767 to #769 in
nexgen_v3): the thank-you page went back to thetransactionshape, the registration page gained thesign_up_free_trialpush, the old container turned out to be unreachable and was replaced byGTM-WXHXW22Mbuilt from the import file, and the trial page CSP was widened for the Google Ads, Facebook, and LinkedIn hosts.