A full OpenAI pixel GTM setup for single-page applications — tag sequencing, History Change triggers, checkout and purchase events, and how to test every one of them in GTM Preview before you publish.
If you have started running ChatGPT Ads, you have probably already found the part nobody warns you about: getting the ads live is the easy half. Getting conversions to report back is where most setups quietly fail — and on a single-page website, they fail in a way that looks like everything is working.
This guide walks through a complete implementation on a live product: Mobile Mandir, our devotional app at mobilemandirapp.com. It runs on Next.js, which makes it a single-page application, which means the standard tracking advice does not work. We will cover the OpenAI pixel GTM setup, tag sequencing, page view tracking across seven content sections, seva checkout and purchase events, and — the part most guides skip entirely — how to verify each one before you publish.
Before the fix, the diagnosis. If your ChatGPT Ads pixel is not firing — or fires but reports nothing — it is almost always one of five things, in roughly this order of frequency:
<script> wrapper, so GTM injects your JavaScript as plain text and never executes it. The tag still reports “Fired”.oaiq was undefined and the call threw. Again: reported as “Fired”.Each one is covered below with the exact fix. If you only have five minutes, the first two are where the odds are.
ChatGPT Ads places sponsored results inside ChatGPT conversations. For a category like devotional content that is genuinely new territory — you are reaching someone at the moment they are asking a question, not scrolling a feed.
Measurement works the way most ad platforms do. OpenAI gives you a pixel: a JavaScript SDK exposing a single global function, oaiq. Install it once, then call it whenever something worth measuring happens.
Three arguments matter: the command (always "measure"), the event name, and an event data object whose type field must match the shape that event expects. OpenAI defines three shapes:
contents — page and product interactions: page_viewed, contents_viewed, items_added, checkout_started, order_createdcustomer_action — lead and signup events: lead_created, registration_completed, appointment_scheduledplan_enrollment — subscriptions: subscription_created, trial_startedGet the type wrong and the event is rejected silently. That is the first thing to check when events appear to fire but never arrive.
Here is the problem in one sentence: on a single-page application, navigating between pages does not load a new page.
On a traditional website, clicking a link tears down the document and builds a new one. The GTM container re-initialises, fires its gtm.js event, and every Page View trigger re-evaluates. That is the model almost every tracking tutorial assumes.
A React, Vue or Next.js site does not do this. Clicking from the Mobile Mandir home page to the Aarti section calls history.pushState(). The URL in the address bar changes to /aarti, the content swaps, and the document is never reloaded. GTM has no idea anything happened.
This is exactly what we hit. The base pixel fired perfectly. The Aarti tag sat in Tags Not Fired.
The obvious next move is to check the Page URL variable — and this is where the trap springs. It reads exactly what you expect:
The trap: variables in Tag Assistant belong to the event you have selected in the left rail — not to the event the trigger was evaluated on. Your trigger was tested back when the document loaded, on a completely different URL. Everything looks right and nothing fires.
Opening the tag itself confirms the configuration is sound — valid firing trigger, no blocking triggers, correct HTML. The configuration was never the problem. The navigation model was.
Most guides still tell you to paste the pixel into a Custom HTML tag. You no longer need to. OpenAI has published an official tag template in the GTM Community Template Gallery, and it removes an entire category of problems.
In GTM: Templates → Tag Templates → Search Gallery → search for “OpenAI Ads Measurement Pixel” → Add to workspace. It will then appear as a native tag type.
Why this is worth switching to:
<script> wrapper — the single most common reason a Custom HTML pixel tag reports “Fired” while executing nothing at allUse the same Pixel ID on every OpenAI tag in the container. If you are staying on Custom HTML for any reason, the base snippet is:
The <script> tags are mandatory — GTM’s Custom HTML tag injects its contents as HTML, so raw JavaScript becomes a text node and never runs. And when you inspect the tag later you will see it rendered as type=\"text/gtmscript\" with escaped quotes. That is normal GTM behaviour, not corruption. Rewriting it will not help.
Keep debug: true while building. Remove it before you publish.
This step is easy to skip and expensive to skip. Your base pixel and your event tags typically fire on the same event, and GTM does not guarantee the order they run in. If an event tag executes before the pixel has initialised, oaiq is undefined, the call throws, and the tag still reports “Fired” in Tag Assistant. You get a green tick and zero data.
On every OpenAI event tag: Advanced Settings → Tag Sequencing → tick “Fire a tag before this tag fires” → select your base pixel tag.
What correct looks like: on Mobile Mandir the base pixel shows “Fired 8 times” for seven content event tags plus its own trigger. That is sequencing doing its job — the SDK snippet guards itself with an early return, so repeated initialisation is harmless.
Your base pixel covers the first document load of the session. Every navigation after that is invisible to it. You need a second signal.
GTM ships a History Change trigger that listens for pushState calls. Triggers → New → History Change → Some History Changes, with a condition matching the route:
Do not remove your Page View trigger. Attach both to the same tag. Someone clicking a ChatGPT ad lands directly on the URL — a real document load that fires Page View, not History Change. Swap to History Change alone and you will track internal browsing perfectly while missing the exact paid traffic you are paying to measure.
Next.js frequently pushes history more than once per navigation, so a single route change can fire your tag two or three times. You will see it immediately in Tag Assistant as “Fired 2 times” on one tag while its neighbours show “Fired 1 time”.
Harden the trigger with a second condition using Data Layer Variables instead of Page URL:
gtm.newUrl is the value the history event itself carried, so it cannot go stale mid-navigation the way Page URL cangtm.oldUrl stops the tag re-firing when the route pushes again on a URL it is already onGTM pushes both keys into the data layer on every history change. Create them once as Data Layer Variables and reuse them across every route tag.
History Change is a workaround. If you can get twenty minutes of developer time, do this instead — it is more reliable, self-documenting, and carries real data with the event:
Then in GTM: Trigger type → Custom Event, event name mm_virtual_pageview. You now control exactly when the event fires and what rides along with it, instead of inferring intent from a URL string. Feed those values into the tag’s contents array:
Populate contents, always. An event sent with an empty contents array arrives at OpenAI as {"contents": [], "type": "contents"}. If seven different section tags all send that identical payload, nothing in the data distinguishes an Aarti view from a Katha view. Name your conversions carefully in the dashboard and give each event real content data.
Page views tell you nothing about whether the campaign is working. On Mobile Mandir the events carrying money are the seva flow: someone starts a booking, then completes payment.
Have your developer push this when the user reaches the payment step:
The GTM tag, on a Custom Event trigger for mm_checkout_started:
This fires on the payment success screen — on confirmed payment, never on redirect to the gateway. Pass the order ID as an event_id:
That event_id matters more than it looks. When you later add OpenAI’s server-side Conversions API — and for payment events you should, because browser events get blocked — matching on pixel ID, event name and event_id is how OpenAI knows the two reports are one order rather than two sales.
Field discipline: integers for amount and quantity, and only documented fields inside contents[] — id, name, content_type, quantity. Adding something sensible-looking like price gets the event rejected.
App signup uses a different shape entirely:
This is where most implementations go wrong, because “Fired” in Tag Assistant proves far less than people assume. It confirms GTM executed the tag. It says nothing about whether the JavaScript ran, or whether OpenAI received anything.
Work through four checks in order. Do not skip to the last one.
Page URL and gtm.newUrl at that event.
Two things to read carefully in that panel. The base pixel shows “Fired 8 times” — sequencing working. And the Aarti tag shows “Fired 2 times” where its siblings show one: that is the Next.js duplicate-history issue, caught before it ever reached reporting.
If Tag Assistant reports Fired but nothing ever reaches OpenAI, the tag is almost certainly executing nothing. Re-read the HTML field: raw JavaScript with no <script> wrapper is injected as a text node, and GTM has no way to tell you. This is the single most common cause of a GTM Custom HTML tag not working, and it is invisible in every panel except the Network tab.
Switching that tag to the official OpenAI template removes the failure mode entirely, which is the main practical argument for using it.
With debug: true set, open the browser console. The OpenAI SDK logs what it accepts and rejects. A rejection usually means a bad type field or an undocumented property inside contents[].
DevTools → Network, filter for “openai” or “bzrcdn”. One outbound request per event. No request means nothing was sent, whatever Tag Assistant claims.
In ChatGPT Ads: Conversions → Event Stream, filtered to your pixel. Events land within seconds with full detail — event type, API channel, action source, identifiers, and the raw event data JSON.
Use Event Stream, not the Conversion Events tab. Those counts lag, and a fresh setup showing 0 there while events stream in is normal — it will send you chasing a problem that does not exist.
You will likely see Identifier coverage at 0%, with Oppref, Email and External ID all “Not provided”. For your own manual tests that is expected — you did not arrive via an ad click, so there is no oppref to capture.
If it stays at 0% for real traffic, conversions cannot be attributed back to ad clicks and the campaign optimises on nothing. Pass hashed user data at initialisation:
Normalise before hashing: emails trimmed and lowercased; phone numbers stripped of the leading +, leading zeroes, spaces, brackets, dots and hyphens, leaving 8–15 digits. Hash client-side — never send raw values.
Once every route and the seva flow are wired up, your conversion list stops being a single page view and starts describing the actual product:
Seva Completed maps to order_created. The content sections map to page views. That distinction is the whole point: you can now tell the campaign to optimise toward completed sevas rather than toward whoever loads a page most cheaply.
The failure modes we hit, and what each actually means:
| Symptom | Likely cause | Fix |
|---|---|---|
| Tag shows Fired, OpenAI receives nothing | Custom HTML has no <script> wrapper, so nothing executed | Wrap the JavaScript in <script> tags, or switch to the official template |
| Tag shows Fired, console throws | Event tag ran before the base pixel initialised | Set the base pixel as a setup tag under Tag Sequencing |
| Tag never fires; Page URL looks correct | SPA navigation — no gtm.js on route change | Add a History Change or Custom Event trigger alongside Page View |
| Tag fires 2–3× per navigation | Next.js pushes history multiple times | Add condition: gtm.oldUrl does not contain the path |
| Event rejected in console | Wrong type value, or undocumented field in contents | Match the documented shape; integers for amount and quantity |
| Works internally, not from ads | Page View trigger was replaced by History Change | Attach both triggers to the same tag |
| Conversions indistinguishable from each other | Empty contents array on every event | Populate id, name and content_type per section |
| Identifier coverage stuck at 0% | No hashed user data being passed | Add the user object to oaiq init |
| Conversion Events shows 0 | Dashboard aggregation lag | Check Event Stream instead |
gtm.oldUrl conditioncontents arrayorder_created carries amount, currency and an event_iddebug: true removedIf you take a single thing from this guide, make it the last checklist item. A page view is not a conversion. Optimise a ChatGPT Ads campaign toward page_viewed and the algorithm will do exactly what you asked — find you the cheapest possible visitors, in volume, who do nothing. Point it at order_created with a real INR amount attached and it optimises toward people who complete a seva.
The tracking work in this guide exists to make that second option possible. Everything else is plumbing.
New to the platform itself? Start with our complete guide to ChatGPT Ads, then come back here to wire up the measurement.
Setting up ChatGPT Ads tracking on your own site and hitting the same walls? Seoily builds and audits measurement stacks across GA4, GTM, Meta and the new AI ad platforms.
Pixel installation, tag sequencing, SPA route tracking, purchase events and server-side conversions — wired up, verified in Event Stream, and pointed at the conversion that actually earns money.
Grow your business using the tools, strategies, and support it needs to excel within the industry. Contact Seoily today to find out how our end-to-end solution-based approach can help you turn challenges into solutions to success.