What the ChatGPT Ads API does differently
Our fifth ad platform. Here are the refusals a real ChatGPT Ads account sent back, why the documentation did not warn us, and what each one means for you.
After Reddit, we knew better than to trust a green test suite.
When we integrated Reddit Ads we wrote up why it was harder than it looked. The short version: a mock written from the documentation is a copy of your own assumptions, and only a real account can disagree with it.
So when OpenAI opened ChatGPT Ads to advertisers, we built the adapter the same way as before and then pointed the whole stack at a real ad account before believing any of it. The account disagreed with us on eleven separate points. None of them are in the getting-started guide.
If your team is integrating with the ChatGPT Ads API, here is what to expect.
It connects with a key, not a login
Google, Microsoft, Reddit and Meta all connect through OAuth: the user clicks Connect, approves the app, and tokens come back. ChatGPT Ads hands you an Advertiser API key in its Ads Manager, and that key is the connection. There is an OAuth flow as well, but it is optional, and it only exists once you have registered an OAuth client with OpenAI.
That sounds like less work. It was more, because every assumption we had built up over four platforms said a connection starts with app credentials that an administrator configures. Nothing in our stack could build a client for a platform that needed none, so the screen that was supposed to accept the pasted key failed before the key was ever read.
Lesson: the first thing a new platform tests is not your adapter. It is every "obviously true" rule the previous integrations left behind.
The click id is reserved
We wanted click attribution from the first campaign, so every campaign carried
a landing-page query template with the platform's click parameter in it, the
way you would set up gclid tracking:
oppref={oppref}
The account refused the campaign:
400 Invalid landing_page_configuration.query_string_template:
landing page query string template contains reserved query parameter(s): ['oppref']
ChatGPT Ads appends oppref to every landing URL itself. Naming it in your
template is an error, not a no-op. You get the click id on the landing page
either way; you just may not ask for it.
The minimum budget is only ever a refusal
The documentation says the lifetime minimum is one unit of currency and that the daily minimum is "per currency". It does not say what any of them are. After Reddit we had a rule about this: never invent a minimum, because an invented one blocks real campaigns.
The first export with a five-euro test budget came back with the number:
400 Daily campaign budget must be at least 15000000 micros.
Fifteen euros a day, on a euro account. We now keep that figure, with the refusal quoted beside it, and only for the currencies that have actually refused. Any other currency still gets the platform's own message, because we still do not know its floor and would rather show you the real one.
An unverified account may only advertise its own website
Campaign created. Ad group created. Then the ad:
403 Account verification must be approved before using a different ad URL.
Our test ads point at growomat.com. This account is registered to a different domain, and until its brand review is approved, every ad has to send traffic to the registered website and nowhere else.
The interesting part is what that status code implies. A 403 on an ad creation reads as "you are not allowed", which on every other platform means the token is wrong and the user should reconnect. Here it means the destination is wrong and the account is still in review. Treating it as a permissions problem would have sent people to reconnect an account that was never disconnected. So the message on our side now says what the platform means: until brand review is approved, ads can only point at the website registered on the account.
Archive does not cascade, and the lists lag behind
Every platform so far removed a campaign's children with it. ChatGPT Ads archives exactly the object you name. A few test runs in, the account had nine paused ad groups and ads sitting under campaigns that no longer existed as far as any list was concerned. Removal has to walk the tree from the leaves up: ads, then ad groups, then the campaign.
Then our teardown check reported a campaign still live, seconds after the
archive call had answered status: archived. Reading the campaign by its id
agreed with the archive. Reading the list did not, yet. A few seconds later
the row was gone from the list altogether, because archived objects are not
listed at all.
Lesson: when a platform's list reads lag its writes, read back by id where you can, and poll where you cannot. A single list read straight after a write is a coin toss, and "not in the list" can mean either "not there yet" or "archived".
A web conversion has to name its page
The Conversions API takes an action_source. A conversion that happened on a
website is web, which is correct, and the host refused it:
400 Missing required field for web events: events[0].source_url.
A web event has to say which page it happened on. Most server-side sources,
a payment webhook or a CRM, have no idea. The value the host accepts when you
cannot name the page is other, which is also the honest one, so that is what
goes up when there is no URL to give.
Events go to the pixel, not to the event setting
ChatGPT Ads has two conversion objects. A pixel is where events are sent. An event setting is a rule over a pixel's events, the thing a campaign can optimise for, such as "purchases in a thirty-day window". Both are listed through the API, both have ids, and both look like a conversion action.
Only one of them is an address. Our first server-side conversion went to the event setting's id:
403: unknown pid
The events endpoint takes a pixel id and nothing else. An event setting knows which pixel it counts over, and that is where the event has to go, however you have modelled the two objects on your side. We had modelled them as interchangeable, and it took several rounds with that one refusal before every place that could hand the sink an id agreed on which id.
One pleasant surprise on the way: the documentation reads as if pixels and Conversions API keys are created only in Ads Manager, and for a plain self-serve account the API creates the pixel fine. Only the key comes from the Ads Manager.
The mock agreed with us, again
Read back over those and count how many a green suite caught. None. Our mock
accepted a template naming oppref, accepted a five-euro budget, accepted an
ad pointing anywhere, cascaded archives, answered list reads instantly, and
posted to whatever id it was handed.
It refuses all of those now, in the platform's own words, with the date of the live refusal noted beside each rule. That is the only kind of mock worth having: one that was wrong on a specific day and was corrected by a specific message from the real system.
Where we landed
The live harness that found all of this is the same one we built after Reddit. Nothing in it names a platform. It reads each platform's own declaration of what it needs, whether the ad must carry an image, what the budget floor is, which click parameter routes a conversion, whether the conversion sink takes a credential of its own, and covering ChatGPT Ads was a matter of pointing it at the new account.
It now walks the whole life of a campaign against the real account: create it paused, upload the image, build the chat card, edit it at every level, import it back, prove a re-sync creates nothing new, read reporting, send a click-attributed conversion through our public API to the Conversions API in validate-only mode, and delete it, checking every step by reading the platform back rather than trusting our own success report. Every campaign it creates stays paused, and the account is clean when it finishes.
Creating and shipping a ChatGPT Ads campaign in Growomat now looks exactly like the other four platforms: build it, validate it, push it, and it lands paused for you to enable. Your ads show up inside ChatGPT answers, your conversions come back attributed, and none of the eleven refusals above ever reach you. They are all encoded now, in the adapter, in the checks that run before an export, and in a mock that refuses the way the platform does.
The difference from Reddit was not that ChatGPT Ads is simpler. It is that this time we asked the account first.
Run all four platforms from one place.
Build, optimize and report on Google, Microsoft, Reddit and Meta campaigns without an agency retainer.
See what it costsRead next
engineering
Why the Reddit Ads API was harder than it looks
We budgeted a day to integrate Reddit Ads and it took two weeks. Here are the refusals we hit, why our tests never caught them, and how it ends.

The ad platform says 100 sales. Your bank says 20.
Ad platforms, analytics and your bank never agree on how many sales your ads made. Why each counts something different, and how we built a number you can check.