Ads Manager is fine for setting up a campaign; to run ten, pull reports every morning or hear about a rejection without watching the screen, you need the ChatGPT Ads API.
We measured almost all of that API's traps while setting up our own campaign: here they are, so you don't pay for them.
What it covers
Campaigns, ad groups, ads, images, custom audiences (currently not supported in campaigns targeting the European Economic Area or Switzerland), conversions, reports and bulk operations, on version v1. Note that sending conversions uses a different key (lesson 4).
Authentication: one key per account
- The key is created in Ads Manager under Settings → API Keys and
travels in the
Authorization: Bearerheader. - It is not the conversions key, which is created under Conversions, behind the key icon: they are easy to mix up.
- Each key belongs to one account. The partner guide says to use the client account's key on every request and to keep it in a server-side secret manager. Never in the browser.
- If you send conversions on behalf of clients, every request carries
the same
integration_sourceidentifier.
Several accounts: there is no MCC
If you come from Google Ads, don't look for the MCC: it doesn't exist. Each advertiser has their own account, which an agency can't create on a client's behalf (the client creates it and invites the agency); one user can access several and switch between them, but with no combined view. Roles are per account: admin, member and viewer.
Through the API, running ten accounts means storing ten keys, and your "MCC" becomes a tool of your own that brings them together. At Ninja Scripts we are building exactly that into our panel.
Limits
- 600 requests per minute per endpoint and 1,200 in total.
- Bulk operations: 10 every 10 seconds per account.
- An optional
Idempotency-Keyheader, so that a retry doesn't duplicate what was already created.
The API's traps
| Trap | What happens | What to do |
|---|---|---|
Updating with PATCH or PUT |
Both return 405 | Update with POST |
| Deleting | There is no DELETE |
Archive what you retire (an archived audience never comes back) |
| Reading right after writing | Eventual consistency: an archived ad read as "paused" instantly and "archived" after 10 seconds | Wait and re-read before treating it as done |
| Listing | Lists don't return the parent: the ad group list doesn't say which campaign each group belongs to | Filter by the parent in the URL |
| Parameters that don't exist | Silently ignored: our report with level and start_date would have returned the account total, with no per-ad breakdown |
Test every parameter with an invalid value: if it doesn't return 400, it doesn't exist |
| Simulating | The management API has no simulation mode and no transactions; the Conversions API does offer validate_only, which validates without saving |
Simulate changes yourself; test conversions with validate_only |
| Amounts | Bids and budgets are sent in micros (1 = 1,000,000; a $60 CPM is sent as $0.06 per impression = 60,000 micros); report spend arrives in the account currency | Convert in one single place in your code |
| Uploading images | Without a MIME type you get a 400 even with a valid PNG; minimum 640×640 | Declare the file type |
| Context hints | The list you send replaces the previous one | Always send the complete list |
| Editing the creative | Creates a new version and another review | Only touch it when you mean to change it |
| Deduplicating pixel and server | The event identifier is event_id in the pixel and id in the Conversions API |
Same value in both, with the same pixel and the same event name |
| An ad that won't serve | Activating isn't enough: a pending account review, an exhausted spend cap or campaign problems block delivery | Request include[]=serving_issues; ours said the account's brand review was in progress |
Two nuances: an empty serving_issues list doesn't promise impressions,
and bid_too_low on an ad group is guidance, not a block. And a 200
only tells you the API didn't complain, not that it did what you meant.
A per-ad report, requested properly
curl -G "https://api.ads.openai.com/v1/ad_account/insights" \
-H "Authorization: Bearer $OPENAI_ADS_API_KEY" \
--data-urlencode "aggregation_level=ad" \
--data-urlencode "time_granularity=daily" \
--data-urlencode 'time_ranges[]={"type":"date_range","since":"2026-09-01","until":"2026-09-15","timezone":"Europe/Madrid"}'
aggregation_level=ad asks for one row per ad;
time_granularity=daily, one per day; and time_ranges[], the period
with its time zone. These are the real names: with an invalid value, all
three return 400. -G moves the data into the URL and
--data-urlencode encodes it (the JSON needs that). The key comes from
an environment variable, never from the command itself.
Before trusting the result, check that the rows carry both ad and day (a total without them gives away an ignored parameter); and the last few days can still change, because spend and conversions arrive late (lesson 5).
💡 Ninja trick: before using a new parameter, call it with an absurd value. If the API returns 400, the parameter exists and is validated; if it returns 200, it doesn't exist and is being ignored.
⚠️ Pitfall: if your tool checks your ads' URLs, those hits can land in your analytics as people arriving from the ad. It happened to us: we now filter them out by requiring each visit to be confirmed by a browser.
Good practice
- Everything is born paused: have your code activate things only after review (the ad preview expires after 24 hours). An active ad needs an active campaign and ad group.
- The key, encrypted on the server: one per account, in a secret manager.
- Change log: what, when, before, after and why. Without it you won't know what caused what (lesson 7).
What to remember
- One key per account, on the server, and a different one for sending conversions.
- There is no MCC: your own tool is the "MCC", using each client's key.
- Limits: 600 requests per minute per endpoint and 1,200 in total; bulk, 10 every 10 seconds.
- POST to update, archive instead of delete, and re-read after writing.
- A parameter that doesn't return 400 with an invalid value doesn't exist.
- Micros when sending bids and budgets; account currency when reading spend.