pixellint

Blog · CAPI dialects

Reddit v3, Snap v3, and OpenAI CAPI landed in one year. One shared worker failed all three.

The market event is not a new hashing tutorial. It is three breaking envelopes on stacks that still emit Graph JSON: Reddit event_at milliseconds and UPPER_SNAKE, Snap v3 path and PURCHASE enum, OpenAI timestamp_ms and order_created. The 200s stay. Attribution does not.

A shared conversion worker was a reasonable 2024 idea. One internal purchase, one JSON, rewrite the host. Meta CAPI taught everyone data[], event_name Purchase, event_time in seconds, action_source website. Reddit, Snap, and ChatGPT ads each published a conversion API that still looks like that paste until you read the path, the clock, and the enum. Then it is a different product.

The docs essay on one CAPI JSON is the contract table. This post is the dated pile-up. Snap told people to leave v2 before it deprecated in early 2025. Reddit published a v3 migration with a new path and a new clock. OpenAI Ads Conversions API is the third break: not a recap of oppref, just the fact that ChatGPT ads CAPI is another envelope Graph JSON cannot serve. One worker failed all three.

Reddit v3: wrap it, rename it, change the clock

Reddit's migration page calls CAPI v3 a breaking change to the request payload. The v2 path is /api/v2.0/conversions/events/{Pixel ID}. v3 is /api/v3/pixels/{Pixel ID}/conversion_events. The JSON grows a data node wrapping the old payload. Existing bearer tokens still work. You do not get a new secret as a consolation prize for missing the path.

event_at becomes an integer Unix epoch in milliseconds. event_at_ms goes away; that job is now event_at. If your worker still sends a ten-digit Meta event_time as event_at, you are three orders of magnitude early. action_source becomes required. event_type becomes type. tracking_type moves to UPPER_SNAKE: PageVisit is PAGE_VISIT, Purchase is PURCHASE, SignUp is SIGN_UP. event_metadata becomes metadata. metadata.value is a double in the currency's base unit. value_decimal is gone.

test_mode is gone; testing is test_id. user.opt_out becomes user.data_processing_options. event_source_url is new for WEBSITE events so Events Manager can see a domain. Reddit still accepts conversion events from versions of CAPI and strongly discourages sending the same event to both (dual-sending). A percentage rollout is the migration they recommend. A shared Graph body pointed at the v3 path is not a rollout. It is a full miss that can still look like HTTP success depending on how you handle the new status codes.

Snap v3: a new path, a PURCHASE enum, WEB not website

Snap's Conversions API introduction is for version 3. Version 2 is the endpoint tr.snapchat.com/v2/conversion. Snap's note on that page: Conversions API V2 will be deprecated in early 2025. Use the migration guide. Web events now POST to tr.snapchat.com/v3/{PIXEL_ID}/events?access_token={TOKEN}. App events use a Snap App ID in the same /v3/{id}/events shape. The old /v2/conversion path is not a synonym.

The body is a data array. event_name is required. The enum is uppercase: PURCHASE, SAVE, START_CHECKOUT, ADD_CART, PAGE_VIEW, APP_INSTALL, and the rest of Snap's list. action_source is WEB, OFFLINE, or MOBILE_APP. event_time is an epoch; Snap accepts seconds or milliseconds and encourages milliseconds. It cannot be more than 7 days in the past. event_source_url is required for website events and must include a protocol. PURCHASE also needs currency and value.

A Graph action_source website is not WEB. A Graph event_name Purchase is not PURCHASE unless you uppercased it on purpose. Snap's example response is status VALID, reason Events have been processed successfully. That is receipt. Dedup still wants event_id matching the Pixel's client_dedup_id, and for purchases a longer window on transaction_id versus custom_data.order_id. Click id is ScCid on the landing URL, sent as sc_click_id. None of that is fbclid. A VALID 200 on a Purchase spelled the Meta way is how Snap never trains.

OpenAI CAPI is the third break, not a Graph rename

ChatGPT ads now have a conversion API. I am not going to recap the launch post. The dialect fact that belongs in this pile-up is the envelope: POST https://bzr.openai.com/v1/events?pid=PIXEL_ID with events[], not data[]. type is order_created, not Purchase. timestamp_ms is Unix milliseconds, within the last seven days and no more than ten minutes in the future. action_source for web is web. The pixel click reference is oppref, and the API does not capture it for you.

amount is integer minor units: 4200 for $42.00 USD, not 42.00. Batches up to 1,000. One failed event fails the full batch. validate_only validates without saving. A 200 from validate_only is not a conversion. Dedup is Pixel ID plus event_name plus id. The CAPI field is type. If you send Graph event_name Purchase as type, you are in custom-or-invalid territory, not order_created.

That is the same class of bug as Reddit's tracking_type and Snap's event_name, with a third host and a third clock. timestamp_ms, event_at, and Snap event_time can all be milliseconds. Meta event_time is seconds. A shared field named event_time cannot serve this set. The worker that still emits Graph JSON will 200 on whichever gateway only checks parse. Ads Manager, Events Manager, and Snap Events Manager will not treat it as the purchase you thought you sent.

The body that failed all three

A Meta CAPI purchase. Reddit v3, Snap v3, and OpenAI CAPI each reject this dialect. Gateways can still 200.

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

Posted to Reddit v3, that object is the wrong path if you still hit /api/v2.0/conversions/events, an unwrapped shape if you forgot the data node, a seconds clock in a milliseconds field, Purchase instead of PURCHASE under type.tracking_type, and website instead of a permitted action_source. Posted to Snap v3 it is the wrong path if you still hit /v2/conversion, website instead of WEB, and Purchase instead of PURCHASE. Posted to OpenAI it is the wrong array name, the wrong event name, the wrong clock unit, and no oppref.

Each vendor still has a 200-shaped success. Snap returns VALID. OpenAI can 200 a batch that never contained order_created. Reddit documents a wider set of status codes than v2, which is not the same as attributing. The shared worker logs look healthy. The three ad accounts do not.

Dialects that do not translate

The year is the story. The worker is the incident.

None of these vendors asked for a new SHA-256 tutorial. Hashing email is the part every implementation guide already agrees on, with small normalize-then-hash differences. The breaks that landed in production were path, wrapper, clock unit, event enum, and click id. Those are the fields a shared Graph emitter copies wrong while still producing JSON that parses.

Snap's deprecation window on v2 was the first calendar alarm. Reddit's v3 migration is an explicit breaking payload. OpenAI Ads CAPI is the third host that will be pasted onto the same worker because it is a conversion API and the internal event is already named Purchase. The compounding is what makes it a news post instead of a docs table: three migrations, one emitter, three silent misses.

A multi-vendor ads account cannot share one POST body. That sentence was true when Meta and TikTok disagreed about clocks. It is louder after Reddit UPPER_SNAKE, Snap /v3/{id}/events, and OpenAI order_created. Keep one internal event. Emit the vendor envelope at the edge. Persist ids and clocks as different columns. Dual-send Reddit v2 and v3 only if you like double-count risk; Reddit tells you not to.

Reddit is launching Conversions API v3, which introduces breaking changes to the current request payload structure. This change requires advertisers using Conversions API v3 to consolidate their integration to comply with the new structure.

Reddit CAPI v3 migration

What to do

Stop pointing one Graph JSON at every new conversions host. For Reddit, move to /api/v3/pixels/{Pixel ID}/conversion_events, wrap in data, send event_at in milliseconds, UPPER_SNAKE tracking types, required action_source. For Snap, POST /v3/{PIXEL_ID}/events, event_name PURCHASE, action_source WEB. For OpenAI, POST bzr.openai.com/v1/events with events[], type order_created, timestamp_ms. Capture each vendor's click id on the landing hit.

Lint the body you actually POST. The dialect guide is the contract page. The OpenAI CAPI guide is the field list for that host. This post is the market fact: three breaking envelopes in one year, and a shared worker failed all three. Pixellint is not affiliated with Reddit, Snap, OpenAI, or Meta. Passing a linter means the artifact matches the published envelope. It does not mean the ad account attributed the order.

If you still need the Graph hop, keep it as a Graph hop. Convert clocks at the edge: floor milliseconds to seconds for Meta event_time, pass milliseconds as Reddit event_at and OpenAI timestamp_ms, follow Snap's seconds-or-milliseconds rule and prefer milliseconds. One fixture cannot serve the set.

Checklist

Sources

Contract pages

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

Read the dialect guide Docs