Tracking ChatGPT Ads conversions on Shopify takes two layers: the OAIQ pixel registered as a Shopify web pixel for browser events, and the Conversions API fed by your orders/paid webhook for the server copy of each order. Both copies carry the same event ID (order_<orderId>), amounts go out in minor units, emails and phones go out as SHA-256 hashes, and you validate the whole chain with validate_only before going live.
What you are building
Before the steps, the shape of the finished setup, because every decision below follows from it.
| Layer | Runs where | Captures | Weak point |
|---|---|---|---|
| OAIQ pixel (browser) | The visitor’s browser, inside a Shopify web pixel sandbox | The oppref click identifier from the landing URL, page and cart events, the checkout | Anything that stops a script from running or a request from leaving the tab |
| Conversions API (server) | Your server or a connector, triggered by Shopify webhooks | The paid order with amount, currency, line items and hashed customer identifiers | Cannot read the browser on its own; needs the click context relayed to it |
The two layers meet on one rule from the OpenAI Conversions API reference: “If you send the same conversion from the pixel and the Conversions API, reuse the same value as the API id and pixel event_id.” OpenAI then keeps one copy and ignores the other. Sending both is not redundancy for its own sake: the browser copy carries the click context, the server copy carries the reliable amount and the hashed match keys. The reasoning behind the two-layer design is covered in more depth in OAIQ pixel vs Conversions API.
Step 1: get your Pixel ID and API key
Both credentials come from the conversions tab of the ChatGPT Ads Manager. The Pixel ID goes into the browser snippet and into the pid query parameter of every API call. The API key is a bearer token: the Conversions API endpoint is POST https://bzr.openai.com/v1/events?pid=<PIXEL-ID> with an Authorization: Bearer <key> header.
Treat the key like a payment secret. It never belongs in the theme, in a web pixel, or in any code that ships to the browser. If you use a connector, check that it encrypts the key at rest; Convrail stores it with AES-256-GCM and verifies it against OpenAI the moment you save it, so a typo is caught before the first batch.
Step 2: install the pixel as a Shopify web pixel
Shopify gives apps a dedicated mechanism for tracking scripts. According to the Shopify web pixels overview, “Web pixels are loaded in a sandbox on a visitor’s browser”, in a “strict sandbox environment using web workers”, and they “honor the consent signals chosen by the customer”. They can “securely access all surfaces, like storefront, checkout and post-purchase pages”, which matters because the checkout and the thank-you page are exactly where a theme-pasted script cannot go.
Practical consequences:
- No theme edit. The pixel is registered through the Web Pixel API by the app. Nothing is pasted into
theme.liquid, nothing breaks when you switch themes, and uninstalling the app removes the pixel. - Consent is handled by Shopify. Shopify runs app pixel callbacks only after the customer’s consent choice allows it, so you do not wire the OAIQ
oaiq("consent", ...)call into your own banner logic on Shopify. - The sandbox limits what a script can touch. A web pixel receives Shopify’s standard events rather than reading the DOM. This is a feature: the pixel gets clean, structured data for
checkout_completed, including the order identifier you will reuse server-side.
If you prefer to hand-roll it, the OpenAI Measurement Pixel reference documents the script at https://bzrcdn.openai.com/sdk/oaiq.min.js, initialized with oaiq("init", { pixelId }), and events fired with oaiq("measure", eventName, eventData, options). Inside a web pixel sandbox you cannot load that script the way a theme would, so a sandbox-native implementation of the same protocol is what Convrail ships: it reads oppref from the landing URL, stores it for 7 days in the first-party __oppref cookie, and fires OpenAI’s image endpoint with your Pixel ID.
Step 3: map Shopify events to OAIQ events
Shopify’s standard web pixel events include page_viewed, product_viewed, collection_viewed, search_submitted, product_added_to_cart, checkout_started and checkout_completed. The Conversions API accepts these event types: appointment_scheduled, checkout_started, contents_viewed, custom, items_added, lead_created, order_created, page_viewed, registration_completed, subscription_created, trial_started, app_installed, app_opened. The mapping Convrail applies:
| Shopify event | OAIQ event type | Sent from |
|---|---|---|
page_viewed | page_viewed | browser |
product_viewed, collection_viewed | contents_viewed | browser |
search_submitted | page_viewed | browser |
product_added_to_cart | items_added | browser |
checkout_started | checkout_started | browser |
checkout_completed | order_created | browser and server, same event ID |
Every browser event carries an event_id. For the upper-funnel events a random unique value is fine. For the order it is not: the ID has to be something your server can rebuild independently, without talking to the browser. order_<orderId> satisfies that, because the Shopify order ID is present both in the checkout_completed event and in the orders/paid webhook payload.
Step 4: send the order from your server
Trigger on orders/paid, not on orders/create
An order can be created and never paid. orders/paid fires when the payment is captured, which is the moment the conversion becomes real. Build the event when that webhook arrives.
Build the event ID
order_5843921078 for Shopify order 5843921078. Same string the pixel used at checkout. Deterministic, so a webhook redelivery produces the same ID and OpenAI treats it as a duplicate rather than a second sale. Webhook handlers should be idempotent on your side too: store the event once per (store, event ID, source).
Convert the amount to minor units
The API wants data.amount as an integer in the currency’s minor unit; the reference gives the example “use 4200 for $42.00”. Worked examples:
| Shopify total | Currency | Minor-unit exponent | data.amount |
|---|---|---|---|
| 129.90 | EUR | 2 | 12990 |
| 42.00 | USD | 2 | 4200 |
| 4200 | JPY | 0 | 4200 |
| 12.500 | KWD | 3 | 12500 |
Do the multiplication with the exponent of the actual currency, not with a hard-coded 100. Float arithmetic on 129.90 * 100 gives 12989.999999999998 in JavaScript; parse the string as an integer of minor units instead, or use a decimal library.
Hash the customer identifiers
The user object accepts hashed fields as lists of lowercase 64-character hex SHA-256 digests: emails_sha256, phone_numbers_sha256, external_ids_sha256, first_names_sha256, last_names_sha256. The normalization rules from the reference:
- Email: trim whitespace, lowercase, then hash.
[email protected]becomes[email protected]. - Phone: keep “8-15 digits after removing a leading
+, leading zeroes, whitespace, parentheses, periods, and hyphens”.+33 6 12 34 56 78becomes33612345678. - Names: lowercase, remove whitespace and ASCII punctuation.
Jean-Pierrebecomesjeanpierre.
Raw fields (not hashed): ip_address, user_agent, countries, regions, cities, postal_codes, obref, android_advertising_id. Never send a clear email or phone in any field, hashed or not. The full normalization and the GDPR reasoning are in Server-side tracking without leaking personal data.
The batch payload
One order_created event with the fields a paid Shopify order can fill. Hashes below are real SHA-256 digests of [email protected], 33612345678 and customer-7781.
{
"validate_only": false,
"integration_source": "convrail",
"events": [
{
"id": "order_5843921078",
"type": "order_created",
"timestamp_ms": 1788687000000,
"action_source": "web",
"source_url": "https://shop.example.com/checkouts/thank_you",
"oppref": "<value captured from the landing URL>",
"user": {
"emails_sha256": ["86e0b9e56c17cc4d12387e1949b85053fbe73bc3ce5a1188713a9d300cc6133d"],
"phone_numbers_sha256": ["8a3e7886c9335e82e02299fa3e87b46e2de3b0c63d56003e30a5029394a47661"],
"external_ids_sha256": ["91d8dedaef47ad2016875e1ecbb6f01c00bba531c0d4c3c5661ed3129ae381b0"],
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15",
"countries": ["FR"]
},
"data": {
"type": "contents",
"amount": 12990,
"currency": "EUR",
"contents": [
{
"id": "SKU-1001-M",
"group_id": "PRD-1001",
"name": "Linen shirt, medium",
"content_type": "product",
"quantity": 1,
"amount": 12990,
"currency": "EUR"
}
]
}
}
]
}
Field rules that trip people up, all from the reference:
source_urlis “Required for web events whenaction_sourceisweb”.timestamp_ms“must be within the last 7 days and no more than 10 minutes in the future”. Server clock drift of a few minutes is tolerated; a backfill older than a week is rejected.eventsholds up to 1,000 events, and “If one event in the batch fails, the full batch fails”. Validate each event before you add it to a batch, otherwise one malformed order blocks 999 good ones.opprefis “An opaque, OpenAI-provided attribution identifier”. Your server does not have it unless the browser relays it. Convrail’s pixel posts a compact copy of each event to Convrail with the same event ID and the storedoppref, so the server copy can carry it.
Retries and failure handling
On 429 or 5xx, retry with exponential backoff and the same event IDs; deduplication makes a retry harmless. On 4xx, do not retry blindly: the payload is wrong and will stay wrong. Keep the failed batch with its HTTP status and error text somewhere you can read. Convrail parks exhausted batches in a dead-letter queue and opens one health alert per store, in the app and by email.
Step 5: test with validate_only
Set "validate_only": true at the top of the batch. The reference describes it as “Validates events without saving them when true”. Send a handful of real-shaped orders and fix everything OpenAI rejects: wrong amount type, missing source_url, a hash that is not 64 lowercase hex characters, a timestamp in seconds instead of milliseconds.
Convrail starts every store in test mode for this reason. Events flow through the whole pipeline (webhook, hashing, batching, HTTP call) and OpenAI checks them, but nothing is counted. When the Events screen shows browser and server copies of the same order arriving side by side with accepted status, switch to live.
What to check in the first 24 hours
- Pairs, not singles. For each real order, one browser event and one server event with the identical
order_<orderId>. A server event alone on every order means the pixel is not firing on the thank-you page; a browser event alone means the webhook or the API call is failing. - Amounts and currency. Pick three orders, compare
data.amountagainst the Shopify total in minor units. Multi-currency stores: check an order in each presentment currency. - Hashes only. Open a sent payload and search for
@. It must not appear anywhere inuser. opprefon ad-driven orders. Land on your store through a real ChatGPT ad click, place a test order, confirm the server event carries the identifier.- Acceptance in the Ads Manager. Conversions API events are the signal the Ads Manager optimizes and reports on; confirm the conversions tab shows the events as received. Attribution follows OpenAI’s rules: “Click-through attribution uses the applicable configured click window. View-through conversions use a fixed one-day window.”
- Nothing in the dead-letter queue. If something is, read the error text before restarting anything.
Do it yourself or use Convrail
An honest comparison. Doing it yourself is entirely feasible for a team that already runs a server-side tracking stack for other channels.
| Concern | Do it yourself | With Convrail |
|---|---|---|
| Pixel installation | Write and publish a Shopify web pixel extension, or use a custom pixel in the Customer events settings | Installed by the app through the Web Pixel API, removed on uninstall |
oppref relay to the server | Build your own endpoint to receive the browser’s oppref and join it with the order | Pixel relays a compact copy of each event with the same event ID |
| Order webhook, hashing, minor units | Your code, your tests; the currency exponent and the float pitfall are yours to handle | Handled, with zero-decimal and three-decimal currencies covered |
| Batching and retries | Your queue, backoff and dead-letter storage | Batches of up to 1,000, exponential backoff on 429 and 5xx, dead-letter queue, health alert |
| Personal data guard | Your code review discipline | Automated guard refuses any payload containing clear personal data, covered by an automated test |
| Test mode | Toggle validate_only in your config | Every store starts in test mode; switch in settings |
| Visibility | Your logs | Events screen with source, status and timestamp per event |
| Cost | Engineering time, ongoing maintenance as the API evolves | Free during early access; no paid plans exist yet |
| Control | Total | You depend on a third party for a conversion-critical path |
The last row is the real counter-argument. If ChatGPT Ads becomes a major channel and you already own a mature tracking pipeline, adding one more destination to it may be cheaper long-term than a dependency. If you do not have that pipeline, building one for a single channel is where the time goes.
Common mistakes
- Random event IDs on the order. The browser and the server must be able to produce the same ID without coordinating. Anything other than a value derived from the order identifier breaks deduplication.
- Amount as a float or in major units.
129.9or"129.90"is not an integer in minor units. The API expects12990. - Sending clear emails “to help matching”. The
userobject only accepts hashed emails, and a clear email in any field is a data leak toward a third party. - Firing the order event on
orders/create. Unpaid and abandoned orders become conversions. - Retrying 4xx responses. The batch is malformed; retrying it wastes your rate limit and hides the bug.
- Skipping
validate_only. The first live batch is a bad time to learn thattimestamp_mswas in seconds. - Pasting the pixel into the theme. A theme script does not follow the visitor into the Shopify checkout; a web pixel does (Shopify lists checkout and post-purchase pages among the surfaces a web pixel can access). Theme-pasted snippets miss the order event from the browser.
What to do next
Install the Convrail Shopify app, connect your Pixel ID and API key, and watch the first order arrive twice with one ID on the tracking page; details on the platform integration are on the Shopify page.
Sources
Frequently asked questions
Do I need to edit my Shopify theme to install the ChatGPT Ads pixel?
No. A Shopify web pixel is registered through the Web Pixel API and runs in the sandbox Shopify provides, so nothing is pasted into theme.liquid. Uninstalling the app that registered it removes it completely.
Will sending the same order from the pixel and the Conversions API count it twice?
No, as long as both copies carry the same ID under the same Pixel ID. OpenAI deduplicates on Pixel ID, event name and event ID, and keeps the first copy it receives.
What amount format does the Conversions API expect for a Shopify order?
An integer in the currency's minor unit, so 12990 for 129.90 EUR and 4200 for 42.00 USD. Zero-decimal currencies such as JPY are sent as the plain amount, 4200 for 4200 JPY.
Can I test ChatGPT Ads conversion tracking without polluting my real data?
Yes. Send your batches with validate_only set to true and OpenAI checks the format without saving the events. Switch the flag off once the payloads are accepted.
How long do I have to send an order to the Conversions API after it happens?
The event timestamp must be within the last 7 days and no more than 10 minutes in the future. A webhook that fires at payment time leaves plenty of room for retries.