# From prompt to purchase: what an AI-native buying journey looks like in your analytics

> A ChatGPT purchase writes a chain of records: feed row, click with oppref or referrer, pixel events mirrored by a server event, attributed order.

Canonical: https://convrail.com/blog/prompt-to-purchase-ai-buying-journey/

An AI-native buying journey writes a chain of records into your systems: a feed row that made the product recommendable, a landing URL carrying `oppref` or a ChatGPT referrer, pixel events (`page_viewed`, `contents_viewed`, `items_added`, `checkout_started`, `order_created`) mirrored by one server-side `order_created` with the same event ID, and an attributed order with its evidence. Break any link and the order still happens, but your analytics cannot explain it.

## A hypothetical shopper, step by step

Everything below follows one invented shopper. Call them Sam. Sam is not a real person, the store is not a real store, and none of the numbers are measurements; they are chosen to make the records readable. The point is to trace what each step writes into your data, because that is the only part of the journey you will ever see.

Sam wants a packable rain jacket for cycling to work and asks ChatGPT for one with a hood that fits over a helmet. What happens next depends on things you control weeks earlier.

## Step 0: the product has to exist in the feed

Before Sam types anything, your catalog has already been delivered to OpenAI as a full snapshot over SFTP, at least daily, in parquet or `jsonl.gz`. The file upload overview asks for "full snapshots on a predictable cadence (at least daily)" under a stable file name that is overwritten on each run.

For the jacket to be a candidate, its row has to be valid. The specification requires nine fields: `item_id`, `title`, `description`, `url`, `brand`, `seller_name`, `image_url`, `availability` and `price`. A row for Sam's jacket, in JSON Lines, might look like this:

```json
{
  "item_id": "JKT-RAIN-M-OLIVE",
  "group_id": "JKT-RAIN",
  "title": "Packable cycling rain jacket, olive, size M",
  "description": "Waterproof 2.5-layer shell with a helmet-compatible hood, underarm vents and a stuff sack. Weight 210 g.",
  "url": "https://shop.example.com/products/rain-jacket?variant=olive-m&utm_source=chatgpt&utm_medium=feed",
  "brand": "Example Outdoor",
  "seller_name": "Example Outdoor Store",
  "image_url": "https://cdn.example.com/jkt-rain-olive-m.jpg",
  "availability": "in_stock",
  "price": "129.00 EUR",
  "variant_dict": { "color": "olive", "size": "M" },
  "is_eligible_search": true,
  "is_ads_eligible": true,
  "target_countries": ["FR", "BE"]
}
```

Two details in that row matter later. The `utm_source` and `utm_medium` parameters in `url` follow the best practices page, which suggests adding "feed attribution parameters to `url` (for example `utm_medium=feed`) when you need feed-specific click tracking." And `is_ads_eligible` is set explicitly, because an omitted value means "disabled unless a feed-level Ads default applies."

**What this writes into your data:** a feed run journal entry. In Convrail, that is `itemsTotal`, `itemsValid`, `itemsRejected`, the diff against the previous run, and a delivery status. If the jacket's row had failed validation (a price written `129,00`, an image in `.webp`), it would appear as one error entry with the field and the rule, and Sam would never see the jacket. The specification documents no per-row error report from OpenAI's side, so this journal is the only place the rejection would be visible.

**What breaks the chain here:** a rejected row, or a product missing from a snapshot. The overview says a missing record persists "for up to 14 days", so a broken export goes unnoticed for two weeks, then the product is gone.

## Step 1: the prompt and the product card

ChatGPT answers Sam with a short list of jackets, each as a product card built from feed data: title, image, price, seller. Your jacket is one of them, either as an organic result or as an ad. From your side, nothing is written at this step: no impression event in your analytics, no query log you can read. What you can know is whether the product was eligible: `is_eligible_search` was `true` (the default when omitted, and the specification adds that "Eligibility does not guarantee display"), and `is_ads_eligible` was `true` if the card was an ad. The best practices page describes the copy that helps here: "Use concise, factual copy that helps users understand products."

**What this writes into your data:** nothing directly. If the card is an ad, the impression is counted on OpenAI's side and later appears in the Advertiser API insights that Convrail imports once a day (impressions, clicks, spend, CTR, CPC, CPM, conversions).

## Step 2: the click, and the identifier it carries

Sam clicks the card. Two versions of this step exist, and they leave different fingerprints.

**Ad click.** The landing URL carries an `oppref` parameter. The Conversions API reference describes it as "An opaque, OpenAI-provided attribution identifier. Pass the original string without modification." The pixel documentation explains what happens on landing: the pixel "captures oppref from the landing page URL" and "stores oppref in a first-party `__oppref` cookie so later page views can reuse it." Convrail's pixel keeps that cookie for 7 days.

**Organic click.** No `oppref`. The landing URL carries whatever you put in the feed `url`, here `utm_source=chatgpt&utm_medium=feed`, and the browser may send a referrer of `chatgpt.com`. "May" is the operative word: referrers are dropped by some browsers, privacy settings and redirects.

So the landing URL for Sam's ad click looks like:

```
https://shop.example.com/products/rain-jacket?variant=olive-m&utm_source=chatgpt&utm_medium=feed&oppref=<opaque-id>
```

**What this writes into your data:** a session with a landing URL and, on the platform side, whatever your store records about the visit's landing page and referrer. That record is what attribution will read at the end.

**What breaks the chain here:** any redirect between the click and the page the pixel loads on that drops query parameters: a geo-redirect that rebuilds the URL, a `www` to apex hop that forgets the query string, a currency selector that reloads without parameters. `oppref` is gone before the pixel ever sees it, and the click downgrades to organic or not attributed at the end of the journey.

## Step 3: page_viewed and contents_viewed

The product page loads with the OAIQ pixel script from `https://bzrcdn.openai.com/sdk/oaiq.min.js`, initialized with your pixel ID. Two events fire: `page_viewed` for the page itself, and `contents_viewed` for the product being looked at. Convrail maps the Shopify `product_viewed` and `collection_viewed` events to `contents_viewed`, and `search_submitted` to `page_viewed`.

A `contents_viewed` call carries what was seen:

```js
oaiq("measure", "contents_viewed", {
  type: "contents",
  amount: 12900,
  currency: "EUR",
  contents: [{ id: "JKT-RAIN-M-OLIVE", group_id: "JKT-RAIN", quantity: 1, amount: 12900, currency: "EUR" }]
}, { event_id: "cv_<session>_JKT-RAIN-M-OLIVE" });
```

Note the amount: `12900`, not `129.00`. The reference is explicit that `data.amount` is "an integer in the currency's standard minor unit. For example, use `4200` for $42.00". The content `id` matches the feed `item_id`, which is how the event is tied back to the product card Sam clicked.

**What this writes into your data:** a browser event with a timestamp, an event ID, the pixel ID, the `oppref` from the cookie, and the content identifiers. Convrail relays a copy of each pixel event to its own store with the same event ID, so you have your own record rather than depending on the Ads Manager for the count.

**What breaks the chain here:** the pixel not loading (theme change, content security policy, script blocker) or consent denied. The pixel documentation states that it "initializes consent to true by default unless you set it to false or the Pixel finds a stored denial", and that with consent false it "doesn't send measurement-event pings." A denial is a legitimate end to the browser chain, which is why the server path in step 6 exists.

## Step 4: items_added

Sam picks size M and adds the jacket to the cart. The pixel fires `items_added` with the same content structure. Convrail maps Shopify's `product_added_to_cart` to it.

**What this writes into your data:** one more browser event in the same session, still carrying `oppref` from the cookie even though the cart page URL does not contain it. That is the practical value of the first-party cookie: the identifier follows the session without being in every URL.

**What breaks the chain here:** a cart hosted on a different domain, where the cookie set on the product domain is not readable. Events after the domain change lose `oppref` unless it is carried over deliberately.

## Step 5: checkout_started

Sam proceeds to checkout. The pixel fires `checkout_started` with the cart total. On Shopify, the checkout runs on Shopify's infrastructure, which is one reason Convrail installs the pixel through the Web Pixel API rather than the theme: a web pixel runs on the checkout pages too, where theme code does not.

**What this writes into your data:** the last browser event before the order. Many sessions with `checkout_started` and no `order_created` is either normal abandonment or a broken thank-you page pixel; step 6 tells you which.

## Step 6: order_created, twice, counted once

Sam pays. Now two things happen, deliberately in parallel.

**In the browser.** The thank-you page fires `order_created` through the pixel, with the order total in minor units and the line items as `contents`. Convrail maps Shopify's `checkout_completed` to it. The event ID is `order_<orderId>`.

**On the server.** Your platform fires an order webhook (`orders/paid` on Shopify, an order hook on WooCommerce). The server builds a Conversions API event and sends it, batched with others, to `POST https://bzr.openai.com/v1/events?pid=<PIXEL-ID>`:

```json
{
  "events": [
    {
      "id": "order_1001",
      "type": "order_created",
      "timestamp_ms": 1756720000000,
      "action_source": "web",
      "source_url": "https://shop.example.com/checkout/thank-you",
      "oppref": "<opaque-id>",
      "user": {
        "emails_sha256": ["<64-char lowercase hex>"],
        "countries": ["FR"]
      },
      "data": {
        "type": "contents",
        "amount": 12900,
        "currency": "EUR",
        "contents": [
          { "id": "JKT-RAIN-M-OLIVE", "group_id": "JKT-RAIN", "quantity": 1, "amount": 12900, "currency": "EUR" }
        ]
      }
    }
  ]
}
```

Three constraints from the reference shape this payload. `timestamp_ms` "must be within the last 7 days and no more than 10 minutes in the future." `source_url` is required for web events and needs a scheme and host. And the email is not an email: it is a SHA-256 hash of the trimmed, lowercased address, as a lowercase 64-character hex string. Convrail hashes emails and phone numbers on receipt, never stores them in clear, and runs an automated guard that blocks any outgoing payload containing clear personal data.

**Why counted once.** The pixel sent `order_created` with `event_id: "order_1001"`. The server sent `order_created` with `id: "order_1001"`, same pixel ID. The reference states that deduplication matches on "Pixel ID, `event_name`, and `id`" and that "OpenAI uses the first event it receives for a matching key and ignores later duplicates." If Sam had blocked the pixel, only the server event exists and the order is still counted. If the webhook was delayed, the pixel event is kept and the server copy ignored. Either way, exactly once.

**What this writes into your data:** two event records with the same ID, one flagged browser and one flagged server, plus a batch record for the server send (accepted, retried, or dead-lettered after the last attempt). The reference notes that "If one event in the batch fails, the full batch fails", so a permanently failed batch means Sam's order, and every other order in that batch, is not in the Ads Manager.

**What breaks the chain here:** different event IDs on the two sides (the order counts twice), a batch refused because one event in it was malformed (the order counts zero times), or a webhook that never fired.

## Step 7: attribution

The order exists in your store. The question is whether your analytics can say where Sam came from. Convrail applies a fixed decision table to the landing URL and referrer as recorded by your platform, first match wins:

| Evidence on the order | Verdict |
| --- | --- |
| Landing URL contains `oppref=<id>` | ChatGPT, ads |
| `utm_source` is chatgpt, openai or chatgpt.com and `utm_medium` is cpc, ppc, paid or ads | ChatGPT, ads |
| Same `utm_source`, any other medium | ChatGPT, organic |
| Referrer is chatgpt.com, chat.openai.com or openai.com (or a subdomain) | ChatGPT, organic |
| None of the above | not attributed |

For Sam's ad click, the first row matches: ads, evidence `oppref`. For the organic version of the same journey, the third row matches on `utm_source=chatgpt&utm_medium=feed`: organic, evidence UTM. If the parameters were stripped and the referrer survived, the fourth row catches it. If everything was stripped, the order is "not attributed", and Convrail stores that rather than guessing.

**What this writes into your data:** an attribution record on the order with medium (ads or organic), method (which rule matched) and evidence (the parameter or referrer value). This is the column you reconcile with the Ads Manager, which attributes on its own side: click-through conversions use "the applicable configured click window", view-through conversions "a fixed one-day window after an eligible ad impression."

## Step 8: the dashboard

Sam's order now appears in the attributed revenue for the day, under ads or organic. If the Advertiser API is connected, spend, clicks and impressions are imported once a day per campaign, and ROAS is computed as attributed ads revenue divided by imported spend. The jacket contributes to the top products list, built from the line items of attributed orders. The order can be exported as CSV with its evidence column.

If a link in the chain had broken, the health monitors would say so: zero browser events in 24 hours while server events arrive (pixel broken), a batch that failed permanently in the last hour (Conversions API errors), a feed run not delivered in 24 hours, or a volume drop below 30% of the 7-day baseline. The [audit guide](/blog/where-stores-lose-chatgpt-signal-audit/) walks through each of those checks by hand.

## The same purchase through a classic search journey

Put Sam's journey next to the one you already know.

| Stage | Classic search journey | ChatGPT journey |
| --- | --- | --- |
| Discovery | Sam types keywords; a results page lists links and shopping ads | Sam describes a need in a sentence; the answer includes product cards built from your feed |
| Your input | Indexed pages, and a Google Shopping feed for ads | A product feed validated against the OpenAI specification, delivered as a daily snapshot |
| Impression visibility | Search console and ads reporting show queries and impressions | No query log on your side; ad impressions come from the Advertiser API, organic impressions are not exposed |
| Click identifier | `gclid` or similar on paid clicks; referrer on organic | `oppref` on paid clicks; your own UTMs and the chatgpt.com referrer on organic |
| On-site events | Analytics tag and a conversion pixel | OAIQ pixel events plus a Conversions API server event, deduplicated on one event ID |
| Attribution | Platform-side and your analytics, on click identifiers and referrers | Ads Manager on `oppref` and click windows; your side on `oppref`, UTMs, referrer |

The structural difference is at the top. In a search journey, the query is visible to you and the link is a page you wrote. In a ChatGPT journey, the query is a conversation you do not see, and the "page" is a card assembled from your feed row. The feed does the job that the indexed page used to do, which is why a rejected row is not a technical footnote but a product that does not exist for that shopper. Everything downstream of the click is familiar: an identifier on the URL, a pixel, a server event, an attribution rule. The names are new; the discipline (one event ID, hashed identifiers, evidence stored with the order) is the same one that makes server-side tracking work on other channels.

## Common mistakes

**Letting a redirect eat `oppref`.** The identifier is on the landing URL once. If the first hop drops it, the pixel never stores it and the ad click ends up labelled organic or not attributed.

**Sending `129.00` as the amount.** Minor units, always: `12900` for 129.00 EUR.

**Generating the event ID randomly on the pixel.** The server cannot reproduce a random ID. Derive it from the order (`order_<orderId>`) on both sides.

**Reading "not attributed" as "not ChatGPT".** It means no evidence survived. A shopper who asked ChatGPT and typed your URL by hand is invisible to these rules, and to every other attribution method.

## What to do next

Read the [attribution rules and their limits](/attribution/) with your own last thirty orders in front of you, and check which row of the decision table each one would land on.