How to Monitor ChatGPT Ads (Technical Guide, 2026)
On this page
ChatGPT carries paid ads, and you can detect them with one API field check. This guide covers the response shape, the detection logic, a working Python script, and how to run this across a priority query set on cadence.
For context on when ads launched, what they cost, and who’s buying, start with the rollout status and the penetration study. This post assumes you already know ads exist and you want to instrument detection.
A note on the tooling landscape before we get into the code. There are competing approaches to this measurement. Adthena’s ChatGPT Ads Intelligence Platform (launched May 11, 2026) tracks 300,000+ daily prompts with whole-market visibility into competitor bids, ad copy, and share of voice. Other AI search monitoring platforms like BrandRank.AI and Otterly approach the same problem from the brand-visibility direction. This guide is for teams that want to instrument the raw signal themselves rather than buy a packaged dashboard.
Table of contents
- The detection signal: response.result.ads[]
- Distinguishing ads from other commercial surfaces
- Working example: a single request
- Working example: batch monitoring across a query set
- Building the alerting layer
- Cost and concurrency planning
- What to do with the data
The detection signal: response.result.ads[]
When ChatGPT serves a paid placement, it returns the ad in response.result.ads[]. The detection logic is one boolean:
has_ad = bool(response["result"].get("ads"))
Empty array or missing field means no ad. Non-empty array means at least one placement rendered.
The structure of each entry in the ads[] array:
{
"ads": [
{
"brand": {
"name": "Rippling",
"url": "https://rippling.com",
"favicon": "https://bzrcdn.openai.com/brand/rippling/favicon.png"
},
"cards": [
{
"title": "Run HR, IT, and Finance in One Platform",
"body": "All your employee data in one system. Free up your team to focus on what matters.",
"url": "https://rippling.com/?utm_source=chatgpt.com&utm_medium=src",
"image": "https://bzrcdn.openai.com/cards/rippling/abc123.png"
}
]
}
]
}
Three things confirm a placement is paid and not organic:
- The
adsarray key. OpenAI keeps a strict separation. Paid placements only ever appear underads[], never insidesources[]or any other field. - Creative assets served from
bzrcdn.openai.com. That’s OpenAI’s brand and creative CDN, used only for paid placement assets. Image URL hittingbzrcdn.openai.com? It’s an ad. utm_source=chatgpt.com&utm_medium=srcon the destination URL. OpenAI tags ad destinations with this UTM pair for conversion attribution. Organic citation links don’t carry it.
Any one of these is enough to identify an ad. cloro’s pipeline checks all three because the redundancy catches edge cases (a placement where the favicon hasn’t been uploaded to the CDN yet, test traffic with non-standard UTMs).
Distinguishing ads from other commercial surfaces
ChatGPT responses carry several commercial-adjacent fields. Conflating them will inflate your measured ad rate by an order of magnitude.
| Field | Paid? | What it is |
|---|---|---|
result.ads[] | Yes | Sponsored brand cards bid through OpenAI’s ads system |
result.shoppingCards[] | No | Single-merchant product cards generated for shopping queries |
result.inlineProducts[] | No | Multi-merchant product comparison rows |
result.sources[] | No | Organic citation source links |
Only ads[] is unambiguously paid. The other three surface products, brands, or merchants, but they come from ChatGPT’s shopping retrieval, not from advertiser bids.
For paid-ad penetration, count ads[] only. For broader commercial visibility (where your brand shows up on any commercial surface), bundle all four, but track them as separate counters. The ChatGPT shopping post covers the non-paid surfaces.
This is the most common error in third-party ChatGPT ad measurement reports. Bundling shopping cards into the ad count produces rates 5-10× higher than the actual paid placement rate.
It’s also worth knowing the standards landscape forming around this. The IAB AI Transparency and Disclosure Framework (January 2026) recommends machine-readable C2PA metadata for AI-related ad disclosures. IAB Tech Lab’s agentic advertising standards extend those rules to AI agents acting on ads autonomously. None of this is mandatory yet, but if you’re building an internal pipeline, structuring your detection data to map cleanly to those standards now is cheap. Retrofitting later is not.
Working example: a single request
A complete request against cloro’s /v1/monitor/chatgpt endpoint, with ad detection on the response:
import os
import requests
API_KEY = os.environ["CLORO_API_KEY"]
def query_chatgpt(prompt: str, country: str = "US") -> dict:
"""Run a single prompt through ChatGPT via cloro, return the parsed response."""
response = requests.post(
"https://api.cloro.dev/v1/monitor/chatgpt",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"prompt": prompt,
"country": country,
"include": {"markdown": True},
},
timeout=120,
)
response.raise_for_status()
return response.json()
def detect_ads(response: dict) -> list[dict]:
"""Return the ads array. Empty list means no paid placement."""
if not response.get("success"):
return []
return response.get("result", {}).get("ads", []) or []
# Use it
result = query_chatgpt("best HR software for a 500-person company")
ads = detect_ads(result)
if ads:
for ad in ads:
brand = ad["brand"]["name"]
headline = ad["cards"][0]["title"]
print(f"AD: {brand} — {headline}")
else:
print("No ads on this response.")
JavaScript equivalent:
const API_KEY = process.env.CLORO_API_KEY;
async function queryChatGPT(prompt, country = "US") {
const response = await fetch("https://api.cloro.dev/v1/monitor/chatgpt", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt,
country,
include: { markdown: true },
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
function detectAds(response) {
if (!response.success) return [];
return response.result?.ads ?? [];
}
const result = await queryChatGPT("best HR software for a 500-person company");
const ads = detectAds(result);
for (const ad of ads) {
console.log(`AD: ${ad.brand.name} — ${ad.cards[0].title}`);
}
The endpoint returns the full ChatGPT response (sources, shopping cards, search queries, brand entities, ads), so the same request powers organic citation tracking and ad detection. You don’t need two pipelines.
Working example: batch monitoring across a query set
The single-request pattern is fine for one-off checks. For real monitoring you want a fixed priority query set (50-200 prompts) running on cadence (usually weekly) with the ad-bearing responses aggregated.
The script below walks a list of priority queries, calls the API in parallel, and writes one row per response to a CSV.
import csv
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
import requests
API_KEY = os.environ["CLORO_API_KEY"]
CONCURRENCY = 8 # tune based on your plan's concurrency limit
def query_chatgpt(prompt: str, country: str = "US") -> dict:
response = requests.post(
"https://api.cloro.dev/v1/monitor/chatgpt",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={"prompt": prompt, "country": country, "include": {"markdown": True}},
timeout=180,
)
response.raise_for_status()
return response.json()
def summarize_response(prompt: str, response: dict) -> dict:
"""Flatten one response into the row we care about for ad monitoring."""
ads = (response.get("result") or {}).get("ads") or []
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"prompt": prompt,
"success": response.get("success", False),
"ad_count": len(ads),
"advertisers": ";".join(ad["brand"]["name"] for ad in ads),
"ad_headlines": ";".join(ad["cards"][0]["title"] for ad in ads if ad.get("cards")),
}
def main(queries_file: Path, output_csv: Path) -> None:
prompts = [line.strip() for line in queries_file.read_text().splitlines() if line.strip()]
print(f"Querying {len(prompts)} prompts at concurrency={CONCURRENCY}...")
rows = []
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
futures = {pool.submit(query_chatgpt, p): p for p in prompts}
for fut in as_completed(futures):
prompt = futures[fut]
try:
rows.append(summarize_response(prompt, fut.result()))
except Exception as exc:
print(f"FAILED: {prompt[:60]} — {exc}", file=sys.stderr)
rows.append({
"timestamp": datetime.now(timezone.utc).isoformat(),
"prompt": prompt,
"success": False,
"ad_count": 0,
"advertisers": "",
"ad_headlines": "",
})
with output_csv.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
total = len(rows)
ad_bearing = sum(1 for r in rows if r["ad_count"] > 0)
print(f"Done. {ad_bearing}/{total} responses carried ads ({ad_bearing/total:.1%}).")
if __name__ == "__main__":
main(Path(sys.argv[1]), Path(sys.argv[2]))
Run it:
python3 monitor_chatgpt_ads.py priority_queries.txt results_2026_05_26.csv
Where priority_queries.txt is one prompt per line. The output CSV has one row per response with the advertisers and headlines that surfaced. Pivot it in any spreadsheet for ad share per query, per advertiser, or per week if you append runs over time.
For more than a few hundred prompts at once, switch to the async endpoint (POST /v1/monitor/chatgpt/async) and receive results via webhook. Sync concurrency is plan-dependent. Async scales without the connection ceiling.
Building the alerting layer
Detection is the easy part. Alerting is where this actually starts being useful, because nobody reads the CSV. A few alerts worth wiring up:
New advertiser appears in your category. Compare today’s run against the trailing 30-day baseline. A brand showing up for the first time is often the highest-signal event in the dataset, since it usually means a competitor just committed real budget.
def new_advertisers(today_rows: list[dict], baseline_rows: list[dict]) -> set[str]:
"""Advertisers in today's run that weren't in the trailing 30 days."""
seen_baseline = {
adv for row in baseline_rows for adv in row["advertisers"].split(";") if adv
}
seen_today = {
adv for row in today_rows for adv in row["advertisers"].split(";") if adv
}
return seen_today - seen_baseline
Your brand appears in a competitor’s ad creative. Comparison ads aren’t common on ChatGPT today, but they happen, and you want to know immediately when a competitor’s headline names your brand. Scan the ad_headlines field for brand mentions.
Penetration rate jumps on your priority queries. If your weekly ad-bearing rate moves from a steady 0.3% to 1.5%, something changed: new advertisers, new placement logic, broader rollout. Investigate before assuming.
Without an alerting layer, this whole pipeline is a CSV nobody reads.
Cost and concurrency planning
A weekly run across 100 priority queries is 400 API calls per month. On cloro’s pay-per-call pricing, that’s a small operational expense, well inside any plan that supports production monitoring. 500 queries per week is 2,000 calls per month, still modest.
The real constraints:
- Concurrency. Sync
/v1/monitor/chatgpthas a per-plan concurrency limit. For batches above the limit, use the async endpoint and consume results via webhook. - Latency. Each ChatGPT request takes 30-90 seconds end-to-end (ChatGPT itself is slow). Plan your batch window accordingly.
- Country coverage. ChatGPT ads are 94% US-locale in our measurement window. Non-US markets return much sparser ad data, often zero.
Polling cadence depends on the rate, and the rate just changed. After the 2026-05-26 spike (26.5% overall, 49% US — see the updated penetration study), daily cadence on the US slice is now worth running because each daily pass has enough ad-bearing responses to track per-advertiser trends. Weekly is still fine for non-US coverage and total-volume monitoring. For historical context: through April-May the global ad rate sat around 0.42% and weekly was the right cadence everywhere — hourly polling burned 24× the API calls without surfacing 24× the ads. If your existing run is anchored on the old 0.42% number, recalibrate the cadence per geography.
Sanity-checking against the published rates
Once you have your own measurement, cross-check it against the public numbers before drawing conclusions. Three anchors:
- cloro’s original April-May study: 0.42% of responses carried an ad across our 19-day measurement window (US-locale, B2B-heavy prompt mix).
- cloro’s 2026-05-26 re-measurement: 26.5% overall, 49.1% within the US — roughly a 60× jump, with the advertiser pool widening by about the same factor. Most plausibly driven by OpenAI expanding ads into anonymous sessions. If your own run is anchored on the old 0.42% number, recalibrate — the floor moved.
- Adthena’s separate analysis (pre-spike): roughly 0.8% on 500+ prompts, with a different prompt mix.
If your post-spike US-slice run produces a number outside roughly 20-60%, check your filtering logic. Counting shoppingCards[] or inlineProducts[] will push you above; missing the bzrcdn.openai.com signal or filtering on non-US prompts will pull you below.
For verification practice broadly, the MRC Ad Verification Supplement is the formal industry standard for content-level brand safety measurement. Vendors like DoubleVerify and IAS build to it. Your homegrown pipeline doesn’t need to match the MRC bar, but knowing what shape they expect helps if your data ever needs to feed an enterprise brand-safety stack.
Watch for sudden penetration shifts
The 2026-05-26 jump is a worked example of why this monitoring layer matters in the first place.
For three weeks after our original April-May study, the published rate was 0.42% and the natural read was that this was equilibrium. A team running daily monitoring would have seen something different: through mid-to-late May, the per-day US rate started climbing — probably gradually, possibly in steps. A team running weekly monitoring would have caught the shift one week later. A team running monthly monitoring would have missed the entire ramp and been blindsided by the new ceiling.
The alert that would have caught this: per-country penetration rate vs trailing 7-day baseline. If today’s US rate is more than 3× the trailing-7-day average, flag it. The jump from sub-1% to 49% blew through that threshold by a wide margin — the alert would have fired on the first day the rollout reached anonymous sessions. Pseudocode:
def detect_penetration_shift(today_rate_us: float, baseline_rate_us: float) -> bool:
"""Fire when today's US rate is materially above the trailing baseline."""
return today_rate_us > max(0.02, baseline_rate_us * 3.0)
For category-defense use cases the alerting matters even more. The 2026-05-26 advertiser pool was roughly an order of magnitude wider than our April-May baseline. Most of the new entrants are spending small amounts on long-tail commercial queries that didn’t exist in the old advertiser mix. If you’re not running monitoring at this cadence, you won’t know which of your category-defining queries a competitor entered until well after they’ve established positioning.
The general pattern: when a paid surface goes from sparse to common in a short window, the alerting layer is the only thing that catches the moment to act. Build it before the next shift, not after.
What to do with the data
A few use cases that justify the monitoring spend.
Category defense. If competitors are buying placements on your priority queries and you aren’t, you have a category-positioning problem regardless of your own ad budget. The data tells you which queries, which competitors, and what the headlines say. From there you can buy placements yourself (if you qualify, see how to advertise on ChatGPT) or invest in stronger organic visibility on those queries.
Pricing benchmarking. If you’re thinking about buying ChatGPT placements, knowing the current market (who’s buying, how often they show up, what the creative looks like) is the basis for negotiating with OpenAI’s sales team. “Three competitors run ads on 40% of our priority queries with these messaging patterns” is a much sharper conversation than “we’d like to explore ChatGPT advertising.”
Brand-safety monitoring. When ChatGPT describes your brand in an answer, you want to know if the description is right, whether competitors are appearing alongside you, and whether anyone is buying paid placements that name your brand. The data is your early warning for misdescription, competitive displacement, and inappropriate ad pairings.
The whole monitoring + alerting stack pays for itself the first time it surfaces a competitive shift that would have taken weeks to spot manually. For broader brand-mention coverage (not just paid placements but every mention), the ChatGPT visibility tracker and mention monitor walkthroughs cover the rest.
For the same pattern across the other AI engines (Perplexity, Gemini, Copilot, Grok, Google AI Overview, AI Mode) on one API key, see cloro’s AI visibility tracking. For the ChatGPT-ads-specific pillar with parsed brand, creative, and country data, see the ChatGPT Ads API.
Frequently asked questions
How do I detect if a ChatGPT response contains an ad?+
Check whether `response.result.ads` is a non-empty array. Ads render in a `response.result.ads[]` array on the ChatGPT response, with each entry carrying a `brand` object and a `cards[]` array. Empty arrays and missing fields mean no ad was placed.
What does the ChatGPT ads[] array structure look like?+
Each entry has a `brand` object (name, URL, favicon) and a `cards[]` array with the creative (image, title, body, deep-linked URL). Creative assets are served from `bzrcdn.openai.com`. Destination URLs carry `utm_source=chatgpt.com&utm_medium=src` — the cleanest signal that the placement is paid.
How can I tell ChatGPT ads from organic citations or shopping cards?+
ChatGPT ads live in `result.ads[]`. Organic citations live in `result.sources[]`. Commercial-but-not-paid surfaces are `result.shoppingCards[]` (single-merchant cards) and `result.inlineProducts[]` (multi-merchant comparison rows). Only `ads[]` is unambiguously paid.
Can I monitor ChatGPT ads without scraping ChatGPT myself?+
Yes. cloro's `/v1/monitor/chatgpt` endpoint returns the parsed ChatGPT response including the `ads[]` array. You send a prompt, get back the structured response, and check the ads field. No browser automation, no captcha solving, no per-engine maintenance.
How often should I poll ChatGPT for ad monitoring?+
Cadence depends on geography. After the 2026-05-26 spike, US-targeted queries now return ads in ~49% of responses, so daily sampling across a 50-200 priority prompt set surfaces enough per-advertiser signal to be worth running. For non-US coverage and total-volume monitoring, weekly is still fine. Through April-May the global ad rate sat around 0.42%, so historical guidance favoring weekly polling was correct for the pre-spike regime — recalibrate if your run was anchored on the old number. Use the async endpoint for batch runs of more than a few hundred prompts at once.
Related reading
ChatGPT ads: 0.42% → 26.5% in three weeks (May 2026 update)
ChatGPT ads measured at 26.5% of responses on 2026-05-26 (49.1% in the US), up from 0.42% in our April-May study. Roughly a 60× jump in three weeks, with the advertiser pool an order of magnitude wider.
ChatGPT visibility tracker: track brand in AI search
Complete guide to using ChatGPT visibility trackers to monitor how your brand appears in AI search results and optimize your presence.
How to monitor ChatGPT mentions of your brand
Learn proven methods to monitor when ChatGPT mentions your brand, track competitor activity, and improve your AI search presence.