← Learn

How to Set Up Offline Conversion Tracking for ChatGPT Ads

ChatGPT Ads bids on the conversions you report. Send it form fills and it buys form fills. This guide covers the whole chain: capturing the click ID on your landing page, keeping it with the lead through your sales process, and posting the closed deal back to the OpenAI Conversions API. No code required.

Lester Visser
Tracking Specialist
Updated · 15 min read

What is offline conversion tracking for ChatGPT Ads?

Offline conversion tracking means telling ChatGPT Ads what happened to a lead after it left your website. Someone clicks a sponsored placement in ChatGPT, lands on your site with an oppref click identifier in the URL, fills in a form, and six weeks later signs a contract. You store that identifier with the lead, and when the deal closes you post an event back to the OpenAI Conversions API carrying the identifier, the outcome and its value. ChatGPT Ads then knows which placement earned the revenue, not just which one earned the form fill.

Click identifier
oppref
Cookie
__oppref, 30 days
Browser identifier
__obref
API
OpenAI Conversions API
Endpoint
POST https://bzr.openai.com/v1/events
Auth
Bearer token
Event timing
Within the last 7 days

What you need before you start

Six things, and you can collect all of them before you touch a single setting. The two credentials both come from the same screen in ChatGPT Ads Manager.

WhatWhere to get it
Pixel IDChatGPT Ads Manager, conversions tabThe same ID the OAIQ pixel uses
Conversions API keyChatGPT Ads Manager, conversions tabA bearer token. Treat it like a password
A conversion eventDefined in ChatGPT Ads Manager before you send anythinglead_created, order_created, or a custom name
A place to store the click IDA custom field on the contact or deal in your CRM, or a column in a sheetPlain text, at least 255 characters
An automation toolMake, Zapier, n8n, or anything that can send an HTTP requestIt needs to POST raw JSON, not just form fields
The OAIQ pixel on your siteOptional, but you need it if you also want browser-side eventsRead the deduplication note in step 4 if you run both

The four moving parts

Every offline conversion setup, on every ad platform, is the same four moving parts in the same order. The parts do not change; only the names of the identifier and the API do.

Read this section once and the rest of the guide is filling in the blanks.

  1. 1

    Capture

    The ad click puts an identifier in your landing page URL. You read it before the visitor navigates away, because it only exists on that first page.

  2. 2

    Carry

    The identifier has to reach your form. A hidden field does this, filled in by a small script when the page loads.

  3. 3

    Store

    The form submission puts the identifier into your CRM alongside the lead, where it waits for as long as your sales cycle takes.

  4. 4

    Send

    When the deal closes, an automation posts an event to the platform API with the stored identifier, the outcome and its value.

Step 1 of 5

Capture the oppref click ID

Get the identifier out of the URL and into a cookie that survives the rest of the visit.

When someone clicks your ad in ChatGPT, OpenAI appends oppref to your landing page URL: https://example.com/pricing?oppref=gAAAAAb123. That parameter exists on the landing page and nowhere else. If the visitor clicks through to another page before filling in your form, it is gone.

The OAIQ pixel writes __oppref for you and keeps it for 30 days. If you run the pixel, you can read the cookie instead of the URL and skip the script below. If you do not run the pixel, or you want the value to survive independently of it, write your own first-party cookie.

The snippet below reads oppref from the URL and stores it for 30 days. On every later pageview there is nothing in the URL to read, so it leaves the stored value alone rather than overwriting it with a blank. Paste it into a Custom HTML tag in Google Tag Manager firing on All Pages, or into your site template before the closing </body>.

Custom HTML tag, fires on All Pageshtml
<script>
(function () {
  var name = 'lt_oppref';
  var url = new URLSearchParams(window.location.search).get('oppref');

  // The URL only carries oppref on the landing page. On every later
  // pageview there is nothing to read, so do not overwrite what we stored.
  if (!url) return;

  var days = 30;
  var expires = new Date(Date.now() + days * 864e5).toUTCString();
  document.cookie =
    name + '=' + encodeURIComponent(url) +
    ';expires=' + expires +
    ';path=/;SameSite=Lax';
})();
</script>
Stored under lt_oppref rather than __oppref so it never collides with the cookie the OAIQ pixel manages.

Check before moving on

  • Open your landing page with ?oppref=test123 on the end.
  • In DevTools, Application, Cookies, confirm lt_oppref exists with the value test123.
  • Navigate to another page on your site and confirm the cookie is still there.
Step 2 of 5

Carry it into your form with a hidden field

Make the identifier part of the form submission, so it arrives wherever your leads arrive.

Add a hidden field to your form and fill it from the cookie when the page loads. Every form builder supports hidden fields, though they each call them something slightly different: a Hidden field type in most WordPress plugins, a hidden input in plain HTML, a hidden question in Typeform.

What matters is the field name, because that is what your CRM and your automation will look for later. Use the same name everywhere. oppref is the obvious choice.

Two rules that save time later. Give the field a default value of none rather than leaving it blank, so a lead with no click ID is visibly different from a lead where your script failed. And make sure the field is long enough: oppref values are encrypted tokens and can run past 100 characters, so a field capped at 50 will silently truncate them into uselessness.

The hidden inputhtml
<input type="hidden" name="oppref" id="oppref" value="none">
Fill it on page loadhtml
<script>
(function () {
  var match = document.cookie.match(/(?:^|;\s*)lt_oppref=([^;]*)/);
  if (!match) return;

  var field = document.getElementById('oppref');
  if (field) field.value = decodeURIComponent(match[1]);
})();
</script>
If your form loads after the page does, which most embedded forms do, run this on the form-ready event your builder fires instead of on page load.

Check before moving on

  • Load your form page with ?oppref=test123.
  • Inspect the form and confirm the hidden input now holds test123 instead of none.
  • Submit a test lead and confirm the value arrives in your inbox or CRM.
Step 3 of 5

Store it with the lead

Keep the identifier attached to the record your sales team actually updates.

Create a custom field on the contact or the deal, name it oppref, and map the form field to it. Any CRM can do this, and so can a Google Sheet if that is where your leads live.

Put it on whichever record gets marked as won. If your team closes deals rather than contacts, the field belongs on the deal, otherwise your automation has to look it up across two objects every time.

Store four more things while you are there, because you will need all of them in step 4 and none of them are recoverable afterwards:

What to store per leadtext
oppref        The click identifier from the hidden field
click_date    When the lead came in, so you can spot stale ones
value         The deal value, filled in when it closes
currency      ISO code, e.g. EUR. Do not assume one currency forever
Email and phone are worth storing too, but only if you plan to add advanced matching later. See the note at the end of step 4.

Check before moving on

  • Submit a test lead through the real form.
  • Open the record in your CRM and confirm the oppref field is populated.
  • Move the record to your won stage and confirm nothing strips the field.
Step 4 of 5

Send the conversion when the deal closes

Post one event to the OpenAI Conversions API with the stored identifier and the outcome.

This is the only step that differs meaningfully between ad platforms, and it is where most setups stall. The good news for ChatGPT Ads is that the minimum payload is small.

The endpoint is POST https://bzr.openai.com/v1/events?pid=<PIXEL-ID>, authenticated with Authorization: Bearer <API-KEY>. Your Pixel ID goes in the query string, not the body.

Because you have oppref, you can leave the user object out entirely. That object exists for advanced matching, which is the fallback for conversions where the click identifier is missing. Skipping it means you never have to hash anything, and that is what keeps this achievable without code.

The minimum payloadjson
{
  "events": [
    {
      "id": "deal-4821",
      "type": "lead_created",
      "timestamp_ms": 1773892800000,
      "oppref": "gAAAAAb123",
      "action_source": "offline",
      "data": {
        "type": "contents",
        "amount": 125000,
        "currency": "EUR"
      }
    }
  ]
}
amount is an integer in minor units. A deal worth 1,250 euro is 125000, not 1250. This is the single most common mistake in the whole setup and nothing warns you about it.
Test it first with validate_onlybash
curl -X POST "https://bzr.openai.com/v1/events?pid=<PIXEL-ID>" \
  -H "Authorization: Bearer <API-KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "validate_only": true,
    "events": [{
      "id": "test-1",
      "type": "lead_created",
      "timestamp_ms": 1773892800000,
      "oppref": "gAAAAAb123",
      "action_source": "offline",
      "data": { "type": "contents", "amount": 125000, "currency": "EUR" }
    }]
  }'
validate_only: true checks your payload without recording anything. Get a clean response here before you point real deals at it.

Building it without code

Make

  1. 1Trigger: Watch Records on your CRM, filtered to deals that reach your won stage.
  2. 2Add an HTTP, Make a request module. Method POST, URL https://bzr.openai.com/v1/events?pid=<PIXEL-ID>.
  3. 3Headers: Authorization with value Bearer <API-KEY>, and Content-Type with value application/json.
  4. 4Body type Raw, content type JSON. Paste the payload above and map the deal ID, the oppref field and the value into it.
  5. 5For the value, wrap it as {{round(deal.value * 100)}} so it goes out in minor units.
  6. 6For the timestamp, use {{timestamp}} for the current time in milliseconds. Do not map the original click date.

Run it once with "validate_only": true in the body before you switch it on.

Zapier

  1. 1Trigger: your CRM's Deal Stage Changed, filtered to the won stage.
  2. 2Action: Webhooks by Zapier, then Custom Request. POST to https://bzr.openai.com/v1/events?pid=<PIXEL-ID>.
  3. 3Headers: Authorization set to Bearer <API-KEY>, Content-Type set to application/json.
  4. 4Data: paste the JSON payload and insert the deal fields with the Zapier field picker.
  5. 5Zapier has no maths in the field picker, so add a Formatter, Numbers, Perform Math Operation step that multiplies the deal value by 100 first.

Use Custom Request, not the plain POST action. The plain POST sends form-encoded data and the API needs raw JSON.

Spreadsheet

  1. 1Keep one row per closed deal with the columns from step 3, plus a Sent column.
  2. 2Build a scheduled scenario that runs daily, reads rows where Sent is empty, and posts each one.
  3. 3Write back to the Sent column after a successful response so the next run skips the row.
  4. 4Watch the 7-day window: a row that sits unsent for longer than that is rejected, which is exactly what the Sent column is there to prevent.

The API accepts up to 1,000 events per request, but if one event in a batch fails the whole batch fails. Send them one at a time until you trust your data.

Check before moving on

  • Send one event with validate_only: true and confirm you get a success response.
  • Send the same event with validate_only: false.
  • Check the conversions tab in ChatGPT Ads Manager. Allow for reporting delay before concluding it did not arrive.
Step 5 of 5

Confirm it is working, and keep confirming

Know that events arrive, and notice when they stop.

A conversion pipeline breaks quietly. The form gets rebuilt, the hidden field does not come back, and nothing errors: leads keep arriving, they just arrive without a click identifier. Months later someone notices that ChatGPT Ads stopped optimising.

Two checks catch almost everything. Once a month, look at the share of new leads where the oppref field says none. If that number jumps, your capture broke, not your traffic. And once a quarter, click one of your own ads, complete the form, and follow the lead all the way through to a test conversion.

Only switch the campaign to conversion-optimised bidding once your events are arriving with consistent volume. Bidding trained on an intermittent signal performs worse than bidding trained on none.

Check before moving on

  • The share of leads with oppref = none is stable month to month.
  • A test deal moved to won produces an event in ChatGPT Ads Manager.
  • Your event count roughly matches your won count for the same period.

What breaks in production

Seven things that go wrong after the setup works. Most of them produce no error at all.

  1. 1.The 7-day timestamp window

    OpenAI rejects events whose timestamp_ms is more than 7 days in the past. You cannot backdate a conversion to the moment of the click, which is what most people try first. Send the event when the deal closes, with the current time, and let oppref carry the attribution back to the original click.

  2. 2.Sending the value in whole currency units

    The amount field is an integer in minor units. Send 1250 for a deal worth 1,250 euro and you have reported a conversion worth 12.50. Bidding then learns that your best leads are worthless. Multiply by 100 and round.

  3. 3.One bad event failing a whole batch

    The API accepts up to 1,000 events per request, but a single invalid event fails the entire batch. If you send in batches, one deal with a missing currency takes the other 999 down with it. Send individually until your data is clean.

  4. 4.Double counting with the OAIQ pixel

    If the pixel fires a conversion in the browser and your automation sends the same conversion server-side, ChatGPT Ads counts two. Send the same value as event_id on the pixel and as id on the API call, against the same Pixel ID, and it counts one.

  5. 5.Consent blocking the capture

    If your consent banner blocks cookies until the visitor accepts, your script cannot store oppref and cannot fill the hidden field. Leads from visitors who decline will always show none. That is correct behaviour, not a bug, but it does mean your conversion volume is lower than your deal volume and you should know by how much.

  6. 6.The form rebuild that drops the hidden field

    The most common way this breaks is a designer rebuilding the form. The hidden field is invisible, so nobody notices it missing. Add it to whatever checklist your team uses before a form goes live.

  7. 7.Assuming the 30-day cookie limits your sales cycle

    It does not. The cookie only has to survive from the click to the form submission. Once oppref is in your CRM it stays there, so a deal that closes after four months still carries its identifier. What the cookie window does limit is a visitor who clicks the ad, leaves, and comes back to convert five weeks later.

What you maintain either way

A custom build is entirely reasonable here. The API is well documented, the payload is small, and steps 1 to 3 are the same work regardless of what sends the event. The question is not whether you can build it, it is what you have signed up to maintain.

Here is the honest split.

You maintain, if you build it

  • The capture script, when the site is rebuilt or the tag manager is cleaned up
  • The hidden field, on every form, forever
  • The automation, when your CRM changes its API or your trigger stops firing
  • The minor-units conversion and the currency, when you start selling in a second currency
  • The deduplication ID, if you ever add the browser pixel
  • A separate build per ad platform, because none of them share an identifier or a payload
  • The monitoring, because nothing here fails loudly

LeadTrackr maintains

  • One script that captures every platform's click identifier, not just oppref
  • The storage, the value mapping and the currency handling
  • The API call, including minor units, deduplication and hashed advanced matching
  • The same pipeline to Google Ads, Meta, Microsoft and LinkedIn from the same lead
  • Alerting when the identifier stops arriving

If ChatGPT Ads is the only platform you run and your form never changes, the custom build will serve you well. The moment you want the same closed deal reported to four platforms with four different identifiers, the maintenance is the whole project.

FAQ

Frequently asked questions

No. The Conversions API works on its own, and for offline conversions it is the only layer that makes sense, because the conversion happens long after the browser session ended. The pixel is useful if you also want browser-side events like page views and form submissions. If you run both, send the same ID on each so the same conversion is not counted twice.
The event timestamp must be within the last 7 days, so you send the event when the deal closes rather than backdating it to the click. OpenAI does not publish a maximum age for the oppref value itself, so a long sales cycle is not obviously a problem, but it is worth testing with your own data before you rely on it.
Not if you send oppref. The user object is optional and exists for advanced matching, which fills the gap when the click identifier is missing. Adding it means SHA-256 hashing with specific normalisation rules, which neither Zapier nor Make does without a code step. Start without it and add it only if a meaningful share of your leads arrive with no oppref.
For lead generation, `lead_created` for a qualified lead and `order_created` for a signed deal. The API also accepts `appointment_scheduled`, `subscription_created`, `trial_started` and a `custom` type with your own name. Whichever you pick, define it in ChatGPT Ads Manager first and keep using the same one.
Then it did not come from a ChatGPT ad, or your capture failed. Send events only for leads that carry an identifier. Sending conversions with neither oppref nor user data gives the platform nothing to attribute, and inflates your conversion count without improving bidding.
The four moving parts are identical. What changes is the identifier you capture (gclid for Google, fbclid for Meta), the API you post to, and the payload shape. If you have built it once, the second platform is mostly a new hidden field and a new automation.

The hard part of offline conversion tracking is not the API call. It is keeping a hidden field alive across three years of website changes and noticing when it stops working. Build the chain, then build the monthly check that tells you it is still connected.

Sources: OpenAI Conversions API reference · Conversion measurement

Get started free

Report closed deals to ChatGPT Ads without building this

LeadTrackr captures the click identifier, holds it against the lead through your sales cycle, and posts the outcome back when the deal closes.

Start Free

5-min setup · cancel anytime