# How to track ChatGPT Ads conversions on Shopify: pixel and Conversions API, step by step

> Register the OAIQ pixel as a Shopify web pixel, send order_created from your server with the same event ID, hash emails and test with validate_only.

Canonical: https://convrail.com/blog/track-chatgpt-ads-conversions-shopify/

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](https://developers.openai.com/ads/conversions-api): "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](/blog/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](https://shopify.dev/docs/apps/build/marketing-analytics/pixels), "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](https://developers.openai.com/ads/measurement-pixel) 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](https://shopify.dev/docs/api/web-pixels-api/standard-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. `Jane.Doe@Example.com ` becomes `jane.doe@example.com`.
- **Phone**: keep "8-15 digits after removing a leading `+`, leading zeroes, whitespace, parentheses, periods, and hyphens". `+33 6 12 34 56 78` becomes `33612345678`.
- **Names**: lowercase, remove whitespace and ASCII punctuation. `Jean-Pierre` becomes `jeanpierre`.

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](/blog/server-side-tracking-without-leaking-pii/).

### The batch payload

One `order_created` event with the fields a paid Shopify order can fill. Hashes below are real SHA-256 digests of `jane.doe@example.com`, `33612345678` and `customer-7781`.

```json
{
  "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_url` is "Required for web events when `action_source` is `web`".
- `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.
- `events` holds 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.
- `oppref` is "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 stored `oppref`, 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

1. **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.
2. **Amounts and currency.** Pick three orders, compare `data.amount` against the Shopify total in minor units. Multi-currency stores: check an order in each presentment currency.
3. **Hashes only.** Open a sent payload and search for `@`. It must not appear anywhere in `user`.
4. **`oppref` on ad-driven orders.** Land on your store through a real ChatGPT ad click, place a test order, confirm the server event carries the identifier.
5. **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."
6. **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.9` or `"129.90"` is not an integer in minor units. The API expects `12990`.
- **Sending clear emails "to help matching".** The `user` object 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 that `timestamp_ms` was 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](/tracking/); details on the platform integration are on the [Shopify page](/platforms/shopify/).