pixellint

Blog · ChatGPT ads

ChatGPT ads have a conversion API. Copying Meta CAPI is how the purchase never attributes.

ChatGPT is selling ads. The conversion hop is a server POST, not a Pixel Helper screenshot. The first integration most stacks will ship is the Meta Conversions API worker with the host rewritten.

That paste looks close enough to ship. There are events, a clock, a hashed email, a click id. Every name is different. The host is bzr.openai.com, not graph.facebook.com. The click reference is oppref, not fbclid. The clock is timestamp_ms in milliseconds, not event_time in seconds. The event is order_created, not Purchase.

A Meta-shaped body on the OpenAI host is how the purchase never attributes. The request can still look live. That is the news, not a new validator feature.

The click id is oppref, not fbclid

OpenAI appends an opaque click reference, oppref, to the landing page URL after an ad click in ChatGPT. The JavaScript pixel can capture it and store it in a first-party cookie. The Conversions API does not. The docs say so in one sentence: unlike the pixel, the API does not capture oppref for you.

That is the same class of bug as dropping fbclid at hosted checkout, with a different cookie and a different field. If the thank-you host is not the landing host, the cookie is gone unless you copied the value through the redirect. Server events then POST without oppref. Click-through matching has nothing to join.

View-through is a separate story. Web events can report view-through conversions on a fixed one-day window after an eligible impression. Ads Manager keeps that as a campaign-level metric. It is not folded into Conversions, which stays the click-through total. CPA, post-click CVR, bidding, billing, and conversion optimization stay click-through-based. Optimizing on a number that includes view-through while the API never saw oppref is how two dashboards disagree and both look internally consistent.

What Conversions actually counts

People will google ChatGPT ads conversion API and land on a setup page that says to send events from the server. They will assume the column named Conversions is every attributed conversion. It is the click-through total. View-through, when the account has it, is a different column with a different window. App lifecycle events stay click-through-based.

validate_only on the request validates events without saving them. A 200 from that path is not a conversion in Ads Manager. A 200 from the live path is not a join either. The join is oppref or a match key OpenAI can use, inside the window the product actually bills on.

The envelope is not Graph

Meta CAPI posts data[] of events, each with event_name, event_time, action_source, and user_data. OpenAI Ads Conversions API posts events[], each with id, type, and timestamp_ms. Pixel ID rides on the query string as pid. Auth is a Conversions API key to bzr.openai.com, not the Advertiser API key used on api.ads.openai.com for setup. Two hosts, two keys. Mixing them is a different miss from mixing field names.

timestamp_ms is an integer Unix time in milliseconds. It must be within the last seven days and no more than ten minutes in the future. Date.now() is already that unit. Math.floor(Date.now() / 1000) is the Meta helper. Sending that ten-digit value as timestamp_ms is too short. The event is outside the window or invalid, depending on how far you truncated it.

type is order_created, lead_created, registration_completed, subscription_created, trial_started, checkout_started, contents_viewed, items_added, page_viewed, appointment_scheduled, or custom. app_installed and app_opened are CAPI-only and need action_source mobile_app. They are not on the JavaScript pixel. Purchase is not in the list. A shared enum named Purchase will be a custom event on one API and a standard event on the other, or invalid on one of them.

action_source for web is web. Meta uses website. Pinterest uses web. Snap uses WEB. Copying the Meta spelling is a silent miss on a vendor that enumerates web. source_url is required when action_source is web. That is not event_source_url. The page URL still has to be HTTPS with a scheme and host.

The body people will send first

A Meta CAPI purchase. This is not an OpenAI Ads event.

{
  "data": [{
    "event_name": "Purchase",
    "event_time": 1773892800,
    "action_source": "website",
    "event_source_url": "https://shop.example.com/checkout",
    "user_data": { "em": ["64ec88ca..."] }
  }]
}

That object is a legal Meta CAPI event if the hash and the rest of user_data are right. Posted to bzr.openai.com it is the wrong array name, the wrong event name, the wrong clock, the wrong action_source key, and no oppref. A gateway that only checks JSON parse can still 200. Ads Manager will not treat it as an order_created conversion.

The OpenAI shape is events[] with id, type order_created, timestamp_ms as thirteen digits, action_source web, source_url, and data.type contents. amount is an integer in the currency's minor unit: 4200 for $42.00 USD, not 42.00. currency is a three-letter ISO 4217 code. Batches can hold up to 1,000 events. If one event in the batch fails, the full batch fails. A mixed batch of good Meta-shaped rows and one valid OpenAI event is a full miss.

Names that do not translate

Matching is a different object

OpenAI puts match keys on events[].user. Every field there is optional. Include only what you have. emails_sha256, phone_numbers_sha256, external_ids_sha256, first_names_sha256, and last_names_sha256 are lists of SHA-256 hex. Geographic fields stay raw strings: countries, cities, postal_codes, regions. ip_address and user_agent stay plaintext. Hashing the IP because a Meta helper hashed em is how the event becomes unmatchable.

Normalize before hashing. Email: trim, lowercase. Phone: keep the country calling code, strip whitespace and punctuation, drop a leading plus and leading zeroes, then hash 8 to 15 digits. External id: trim, keep case. First and last name: lowercase, strip whitespace and ASCII punctuation, keep non-ASCII. José stays josé. The API uses the first three valid unique values in each list and ignores the rest without rejecting the event.

obref is not oppref. oppref is an event-level attribution id from the ad click. obref is an opaque browser reference from the pixel's __obref cookie. Hybrid setups read that cookie in the browser, send it to the server, and put it on events[].user.obref. Stop sending it if the user revokes consent. android_advertising_id is GAID only. IDFA is not supported. All-zero advertising ids are ignored without rejecting the event.

Partners who send events on behalf of advertisers set integration_source at the request root, not inside each event. 1 to 64 ASCII characters, start with a letter or digit, then letters, digits, periods, underscores, or hyphens. The API trims and lowercases it. It identifies the integration. It does not replace the Conversions API key.

Two pipes, two keys, one id

The documented hybrid is the same as every other CAPI: browser pixel plus server POST, same Pixel ID, same event id so the vendor can dedupe. OpenAI uses the first event it receives for a matching key and ignores later duplicates. Dedup is Pixel ID plus event_name plus id. The CAPI field is type. The pixel parameter is event. Custom events also need the same custom_event_name on both sides. If those three disagree, you double-count or you drop one pipe.

Setup keys and event keys are not the same secret. Advertiser API keys talk to api.ads.openai.com to provision a Pixel ID and a Conversions API key. Event requests to bzr.openai.com use the Conversions API key. Putting the Advertiser key on the events host, or the events key on the setup host, is a 401 that looks like a broken worker. It is not an envelope bug.

The image tag is a third path: a GET with pid, event, and data[...] query parameters. It cannot hash an email in the browser. It cannot batch. URL length is the ceiling. Use it for a noscript or email-like GET, not as the Purchase pipe. If you send the same conversion on the image tag and CAPI, the image tag's event_id is the CAPI id. The JavaScript pixel is the one that captures oppref for you. CAPI still needs you to pass it.

What to do before the first campaign

Keep one internal event: name, UTC instant, order id, hashed email, landing URL, click references. At the edge, emit the vendor envelope. Persist Meta event_id and OpenAI id as different columns. Convert the clock there: floor milliseconds to seconds for Meta event_time, pass milliseconds as timestamp_ms. Capture oppref on the landing hit and copy it through checkout. Read __obref if you run the pixel.

Do not point a shared CAPI JSON at a new host. Lint the body you actually POST. The OpenAI pack is vendor/openai-conversions-api. The Meta pack is vendor/meta-conversions-api. One fixture cannot serve both. validate_only is for QA. Strip it in production the same way you strip Meta test_event_code, or the event never trains.

The contract page for the field list is the OpenAI CAPI guide. This post is the market fact: ChatGPT ads are a conversion API hop now, and the dialect is not Graph. Pixellint is not affiliated with OpenAI or Meta. Passing a linter means the artifact matches the published envelope. It does not mean Ads Manager attributed the order.

Unlike the pixel, the API does not capture oppref for you. Capture the value yourself and pass it with the server event when it is available to support click matching.

OpenAI Ads Conversions API

Checklist

Sources

Contract pages

The dated argument is above. These pages are the field lists.

Open the OpenAI CAPI pack Docs