GPT Ads
ChatGPT Ads

Shopify server-side tracking for ChatGPT Ads

Sending Shopify orders to the ChatGPT Ads Conversions API — which webhook to use, how to carry the click reference through checkout, and the field mapping.

By 9 min read

A small server stack sending one solid line to a target, beneath a broken dashed line that fades out

A browser pixel can only report a purchase if the shopper's browser loads the page that fires it. Shopify's own documentation says its checkout_completed event fires "once for each checkout, typically on the Thank you page" — and if that page fails to load, it "isn't triggered at all".

Server-side tracking closes that gap. Shopify tells your server about the order directly, and your server tells ChatGPT Ads. No browser involved.

The concept is simple. On Shopify, the details are where it goes wrong, so this walks through them in order.


The shape of it#

Shopify order  →  webhook to your server  →  POST to the Conversions API

The Conversions API endpoint is:

POST https://bzr.openai.com/v1/events?pid=<PIXEL-ID>
Authorization: Bearer <CONVERSIONS-API-KEY>

The Pixel ID and a separate Conversions API key both come from the conversions tab in Ads Manager. The key is a secret — it lives on your server and never in your theme.

That much is standard. The rest of this article is about the parts a Shopify store has to solve on its own.


Which webhook: orders/create or orders/paid#

Shopify offers both. They fire at different moments:

  • orders/create — "whenever an order is created".
  • orders/paid — "whenever an order is paid", meaning payment was captured or the order was marked as paid.

For many stores those are seconds apart. They drift apart when you capture payments manually, take bank transfers or cash on delivery, or use any payment method that leaves the order pending.

Timing matters more than it looks. The Conversions API rejects any event whose timestamp is more than 7 days old. Separately, OpenAI's event quality check flags server events that don't arrive "within one hour of the action". An orders/paid webhook for a bank-transfer order that settles four days later is late on the first count and at risk on the second.

For most stores, use orders/create. It is the closest match to the moment the purchase happened, and it is what order_created is supposed to represent. If a meaningful share of your orders are paid later — or cancelled before payment — decide deliberately whether you want those counted, rather than letting the webhook choice decide for you.


The hard part: getting the click reference onto the order#

When someone clicks a ChatGPT ad, OpenAI appends a click reference to your landing page URL:

https://yourstore.com/products/trail-shell?oppref=gAAAAAb123

That oppref value is what connects a purchase to the ad click. The OpenAI pixel captures it automatically. The Conversions API does not — OpenAI's documentation says plainly, "Unlike the pixel, the API does not capture oppref for you."

So by the time your server gets a webhook, the oppref is sitting in the shopper's browser, not on the order. You have to move it there.

Why not read it off the order's journey data?#

Shopify orders carry a customerJourneySummary with the landing page of the shopper's first and last visits. It looks like the answer. It isn't, for three reasons documented by Shopify:

  • It can be empty — when cookie tracking was blocked, when the order started as a draft, or when it didn't come from the online store.
  • Shopify doesn't document whether the landing page value keeps its query string, which is exactly the part you need.
  • "Summary details might take up to 48 hours to display." OpenAI wants the event within an hour.

It is useful as a cross-check later. It can't be your primary source.

Carry it in cart attributes#

Cart attributes are Shopify's way of attaching extra information to a cart, and they travel with it into the order. Set one when the shopper arrives, and it is on the order when the webhook fires.

A small script in your theme does it:

// Runs on every storefront page. Gate it on marketing consent where required.
(function () {
  var fromUrl = new URLSearchParams(location.search).get('oppref');
  var cookie = document.cookie.match(/(?:^|; )__oppref=([^;]*)/);
  var oppref = fromUrl || (cookie && decodeURIComponent(cookie[1]));
  if (!oppref || sessionStorage.getItem('oppref_saved') === oppref) return;

  fetch('/cart/update.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      attributes: { __oppref: oppref, __ua: navigator.userAgent }
    })
  }).then(function () { sessionStorage.setItem('oppref_saved', oppref); });
})();

It reads oppref from the URL, falls back to the __oppref cookie the OpenAI pixel sets, and writes it to the cart once per session. The shopper's user agent rides along too, for a reason covered below. If your store uses locale-prefixed URLs, the endpoint becomes /{locale}/cart/update.js.

The double underscore matters. Shopify treats attribute keys starting with __ as private: hidden from Liquid, the Ajax API and checkout, but "visible on the Order details page in the Shopify admin". Customers never see it.

Test it before relying on it. Place an order through a URL with ?oppref=TEST-12345, then open the order and confirm the attribute is there — in the admin and in the API response your server reads. Test express checkouts too: a buy-now button that skips the cart is the most likely place for an attribute to go missing.


Mapping an order to a conversion event#

Once the webhook arrives, fetch the order through the Admin GraphQL API and build the event. Shopify's field on the left, the Conversions API field on the right:

Conversions APIFrom the Shopify orderNotes
idorder_ + order legacyResourceIdMust match the browser event ID exactly
typeorder_created
timestamp_mscreatedAtNot processedAt, not the time you send
opprefcustomAttributes__opprefPass unmodified
source_urlYour store's URLRequired for web events
action_sourceweb
user.emails_sha256emailTrim, lowercase, then hash
user.phone_numbers_sha256phoneDigits only, no +, then hash
user.first_names_sha256billingAddress.firstNameLowercase, strip spaces and punctuation, hash
user.last_names_sha256billingAddress.lastNameSame
user.external_ids_sha256Customer legacyResourceIdA stable customer ID, trimmed, hashed
user.postal_codesbillingAddress.zipRaw, not hashed
user.countriesbillingAddress.countryCodeV2Raw, not hashed
user.ip_addressclientIpThe shopper's IP, not your server's
user.user_agentcustomAttributes__uaThe GraphQL order has no user agent field
data.typecontents
data.amounttotalPriceSet.presentmentMoneyConverted to minor units
data.currencypresentmentCurrencyCodeThe customer's currency

Put together:

{
  "validate_only": false,
  "events": [
    {
      "id": "order_5923847291",
      "type": "order_created",
      "timestamp_ms": 1788102000000,
      "oppref": "gAAAAAb123",
      "source_url": "https://yourstore.com/checkout",
      "action_source": "web",
      "user": {
        "emails_sha256": ["<sha256 of trimmed, lowercased email>"],
        "phone_numbers_sha256": ["<sha256 of 14155552671>"],
        "external_ids_sha256": ["<sha256 of customer ID>"],
        "postal_codes": ["94107"],
        "countries": ["US"],
        "ip_address": "203.0.113.1",
        "user_agent": "Mozilla/5.0 ..."
      },
      "data": {
        "type": "contents",
        "amount": 12900,
        "currency": "USD"
      }
    }
  ]
}

Four details that break this quietly#

Two phone formats. The Conversions API wants the phone number normalised to digits only — country code kept, the + removed: +1 (415) 555-2671 becomes 14155552671. Custom audience uploads want E.164 with the +. Same customer, same number, two formats. Hash the wrong one and it never matches.

Minor units, not decimals. data.amount is an integer in the currency's smallest unit: 12900 for $129.00. Shopify returns money as decimal strings, so convert by parsing the string, not by multiplying a float by 100. And not every currency has two decimals: yen (Japan), won (Korea) and the Icelandic króna have none, while the Bahraini, Iraqi, Jordanian, Kuwaiti, Omani and Tunisian currencies have three. All of those countries can buy ChatGPT ads.

Use presentment money. A Shopify order has two versions of every amount — the shop's currency and the currency the customer actually paid in. Shopify calls the presentment values "the source of truth" and the shop-currency values back-converted at a live rate. Use presentmentMoney and presentmentCurrencyCode, and use the same definition of the total in your browser pixel.

The user agent isn't on the order. OpenAI asks server events to include the shopper's real IP address and user agent — "Do not substitute your server's address or a generic user agent." Shopify's GraphQL Order has clientIp but no user agent field. The only reliable way to have it at webhook time is to capture it in the browser, which is why the theme script above stores __ua alongside oppref.


Running both pixel and server without double counting#

You want the browser pixel and the server event. Each catches purchases the other misses. OpenAI deduplicates them using three things together: your Pixel ID, the event name, and the event ID. It keeps the first copy it receives and ignores the rest.

That only works if both sides produce the identical ID string. The server side uses order_5923847291. Whatever fires the browser order_created event must build exactly the same string from the same order — order_ plus the numeric order ID. Shopify identifies orders both by a numeric ID and by a global ID of the form gid://shopify/Order/5923847291, so normalise both sides to the numeric ID, then check.

Check it in Event Stream. In Ads Manager, Tools → Conversions → Event Stream shows incoming events with their channel. Place a test order and confirm both copies arrive with the same event ID. If they don't match, every order counts twice and your cost per acquisition halves on paper.


Operating the webhook#

A few Shopify behaviours to design around:

  • Verify every request. Check the X-Shopify-Hmac-SHA256 header, an HMAC of the raw body signed with your app's client secret.
  • Answer fast, send later. Shopify expects a 200 response within five seconds. Put the order on a queue and send to OpenAI from there.
  • Expect duplicates. Shopify says your app "might receive the same webhook more than once". OpenAI's deduplication handles repeats with the same event ID, and you can also skip repeats using X-Shopify-Webhook-Id.
  • Expect gaps. Failed deliveries are retried eight times over four hours, and Shopify says delivery "isn't always guaranteed". Run a daily reconciliation job over the last six days of orders and resend them with the same event IDs. Duplicates are ignored, so resending is safe, and six days keeps you inside the seven-day limit.
  • Keep the original time. On every retry, send the order's createdAt timestamp, not the time of the retry.
  • Batch carefully. The API accepts up to 1,000 events per request, but "if one event in the batch fails, the full batch fails". Small batches limit the damage from one bad order.

Protected customer data#

Email, phone, name and address are protected customer data in Shopify. An app without approval for that data gets them redacted, which silently empties most of the user object. If you're building a custom app for your store, request that access before assuming the fields will be there.


Shopify gates browser pixels behind its Customer Privacy API — in regions that require consent, a pixel doesn't run until the shopper agrees. A webhook has no such gate. It fires for every order regardless of what the shopper chose.

That makes respecting consent your server's job. Shopify states that server pixels "are still subject to your store's customer privacy and consent settings", and its protected data requirements include respecting customer consent decisions. I couldn't find any field on the order that exposes the shopper's cookie consent choice, so record it yourself:

  • Only run the theme script above when the Customer Privacy API reports marketingAllowed(). No consent, no oppref on the order.
  • Honour data-sale opt-outs. Shopify exposes these per customer as dataSaleOptOut.
  • OpenAI's API has an opt_out flag that opts an event out of future user-level personalisation.

OpenAI's own guidance is to send conversion data only "after providing clear and comprehensive information to users… and obtaining all necessary consents where required by law." Which consents you need depends on where your customers are, and is a question for whoever handles your legal compliance, not your webhook handler. There's more on this in tracking without mishandling customer data.


Testing before you trust it#

  1. Send with validate_only: true. The API checks events without saving them. Fix field errors here, not in production.
  2. Place a real test order through a URL with a test oppref, and confirm the cart attribute reaches the order.
  3. Watch Event Stream for both the pixel and server copies, with matching event IDs.
  4. Read the warnings. Tools → Conversions → Warnings flags delayed server events, missing click information and unmatched duplicates. Its assessment covers seven days, so give it a day or two after a change.

When the event ID matches, the click reference arrives and events show up within the hour, the server side is doing its job. For why that matters to your reported numbers, see why ChatGPT shows fewer conversions than you actually got.


I build LLM Pixels, a Shopify app that runs the browser pixel and server-side purchases for ChatGPT Ads, with deduplication handled for you.