Brand Protection API
Brand abuse shows up where buyers look before it hits revenue: AI answers that recommend unofficial sellers, ads bidding on your name, lookalike domains ranking, counterfeit listings in shopping results.
Who it's for
Brand-protection vendors, in-house IP and trust-and-safety teams, and e-commerce brands monitoring their own name.
What you get
- Shopping cards in AI answers, with the stores they list
- Which sellers AI engines recommend for brand prompts, with sources
- Ad positions and organic results on branded queries, showing who bids on your name and where impersonators surface
Try it
curl -X POST "https://api.answerline.dev/v1/monitor/chatgpt" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt":"Where can I buy Acme products at a discount?","country":"US","include":{"shopping":true}}'
Engines for this workload
What brand abuse looks like in the data
Brand abuse is frequently visible on a result page or in an AI answer before customers report it. Someone searches for your brand and sees an ad from a domain you do not own, a lookalike site ranking under your name, a shopping card offering your product at an implausible price, or an assistant recommending a store you have never heard of. Each of these is a field in a structured response. Google Search and AI Mode carry most of the ad and seller fields below; ChatGPT adds the sources it cites and the merchant offers in inlineProducts, in 213 countries; its shoppingCards and ads fields come back empty on the answers served today.
| Abuse pattern | Where it shows up |
|---|---|
| Brand bidding by unauthorized advertisers | Google Search result.ads[] with type RESULT: title, description, url, displayedUrl, domain, blockPosition |
| Sponsored listings from unknown stores | result.ads[] with type SHOPPING_CARD: store, price, oldPrice |
| Lookalike or impersonation domains ranking | result.organicResults[].link for branded queries |
| Unauthorized or suspicious sellers | Google result.shoppingCards[]: store, price, promoLabel |
| Ads inside AI summaries | result.aioverview.ads[] (TEXT with domain, SHOPPING with store and price) |
| Sellers recommended by assistants | ChatGPT result.inlineProducts[].product.offers[] (merchant_name, price, url; shoppingCards is empty on the answers served today); AI Mode result.shoppingCards[] and inlineProducts[] (store, price) |
| Sponsored placements in assistants | AI Mode result.ads.ads[]; ChatGPT’s result.ads[] comes back empty on the answers served today |
| Impersonation sources cited by assistants | result.sources[].url on brand questions |
The data records what public search pages and AI answers displayed for a query and market at a moment. It does not establish infringement, identify the people behind a domain, or remove anything. It gives an enforcement team timely, consistent, timestamped candidates.
Metrics
| Metric | Definition |
|---|---|
| Unauthorized ad rate | Checks of branded queries with at least one ad from a non-allowlisted advertiser ÷ checks. |
| Unauthorized seller share | Seller appearances (shopping cards, sponsored cards, AI offers) from non-allowlisted stores ÷ all seller appearances on branded queries. |
| Lookalike exposure | Branded queries with a lookalike domain in the top 10 organic results ÷ branded queries checked. |
| Price anomaly count | Offers for a tracked product below a floor you set (for example 60% of list price) in a period. |
| Time to detection | First sighting of a case − the earliest time you could have seen it (bounded by your check interval). |
| Persistence | Days between first and last sighting of an unresolved case. |
| Recurrence | Cases closed as removed that are sighted again within 30 days ÷ cases closed as removed. |
| AI seller exposure | AI answers to brand shopping prompts that include a non-allowlisted merchant ÷ answers collected. |
Report time to detection and persistence per channel (search ads, organic, shopping, AI answers). Each channel has its own enforcement route, so set targets per channel.
Architecture
branded queries & prompts ──> scheduler (per risk tier) ──> batches ──> webhook
│
allowlist (domains, stores, merchants) ──> classifier ◄── parse sightings┘
│
cases (one per actor × channel) ──> review ──> enforcement log
Query and prompt design
- Branded queries: the brand, brand + product names, and brand + the modifiers people use when hunting for a deal or support: “official store”, “outlet”, “discount”, “coupon”, “sale”, “customer service”, “login”, “app download”.
- Product queries: flagship product names with and without the brand, where counterfeit sellers compete.
- AI prompts a shopper would ask: “Where can I buy Acme TrailRunner 3 cheapest?”, “Is acme-outlet-store.com legit?”, “What is the official Acme store?”.
- Markets: run each query in every market you sell in (
country).
Allowlist
The classifier is only as good as the allowlist. Maintain:
- Owned domains and subdomains.
- Authorized retailers: their domains, their
storenames as Google displays them, and theirmerchant_nameas ChatGPT displays them. These strings differ (“Acme Official Store” vs “acme.com”), so store several per retailer. - Known legitimate third parties: marketplaces you sell on, review and comparison sites, press.
Everything else is a candidate, and candidates are reviewed, not auto-actioned.
Schedule
| Tier | Cadence | Examples |
|---|---|---|
| High | Every 6 hours | Brand + “discount”, “outlet”, “official store” on Google |
| Standard | Daily | Product names on Google; AI shopping prompts on ChatGPT and AI Mode |
| Low | Weekly | Long-tail product variants, secondary markets |
Idempotency keys encode the slot:
bp:{query_id}:{engine}:{country}:{yyyy-mm-ddThh}
Give high-tier tasks a higher priority so they run before low-tier ones in your queue. Batch and delivery behavior is in async tasks and webhooks, and the AI visibility tracking guide has a complete receiver.
Evidence
Enforcement requests usually need proof of what was displayed. Store each task’s full response with its task id and completion time in write-once storage. On ChatGPT, Gemini, Google Search, AI Mode and Google News, include.html adds html, a link to a copy of the page the answer came from as it was when captured; store that copy with the response. On Perplexity and Grok, html links the answer’s public page on perplexity.ai or grok.com. Copilot refuses the flag with a 400, so there the stored response, with its text, sources and result fields, is the record.
Storage schema
create table allowlist (
kind text not null, -- domain | store | merchant
value text not null, -- lowercased
owner text not null, -- 'us', retailer name, 'marketplace', ...
primary key (kind, value)
);
create table checks (
task_id uuid primary key,
query_id text not null,
engine text not null,
country text not null,
slot timestamptz not null,
status text not null default 'QUEUED',
completed_at timestamptz,
evidence_uri text
);
create table sightings (
sighting_id bigserial primary key,
task_id uuid references checks,
channel text not null, -- search_ad | shopping_ad | organic | shopping_card | aio_ad | ai_offer | ai_ad | ai_source
actor_kind text not null, -- domain | store | merchant
actor text not null,
position int,
title text,
url text,
price_raw text,
price_value numeric
);
create unique index sightings_once on sightings (task_id, channel, actor_kind, actor, coalesce(url, ''));
create table cases (
case_id bigserial primary key,
channel text not null,
actor_kind text not null,
actor text not null,
first_seen timestamptz not null,
last_seen timestamptz not null,
sightings int not null default 1,
status text not null default 'open', -- open | not_abuse | reported | removed | recurred
unique (channel, actor_kind, actor)
);
Cases and recurrence
A case is one actor on one channel. A new sighting of an existing case updates last_seen and increments sightings; only the first sighting creates a review item. A case closed as removed that is sighted again moves to recurred and should reach a reviewer quickly.
End-to-end Python
Build the tasks
import datetime as dt
HOOK = {"url": "https://hooks.example.com/brand"}
def build(q, slot):
base = {"webhook": HOOK, "priority": q["priority"]}
stamp = f"{slot:%Y-%m-%dT%H}"
if q["engine"] == "GOOGLE":
yield {**base, "taskType": "GOOGLE",
"payload": {"query": q["text"], "country": q["country"],
"include": {"aioverview": {"markdown": False}}},
"idempotencyKey": f"bp:{q['id']}:GOOGLE:{q['country']}:{stamp}"}
elif q["engine"] == "CHATGPT":
yield {**base, "taskType": "CHATGPT",
"payload": {"prompt": q["text"], "country": q["country"], "include": {"shopping": True}},
"idempotencyKey": f"bp:{q['id']}:CHATGPT:{q['country']}:{stamp}"}
elif q["engine"] == "AIMODE":
yield {**base, "taskType": "AIMODE", "payload": {"prompt": q["text"], "country": q["country"]},
"idempotencyKey": f"bp:{q['id']}:AIMODE:{q['country']}:{stamp}"}
Submit in chunks of 500 and insert a checks row per accepted task. The hour stamp (2026-09-17T06) has no colon, so key.split(":", 4) recovers query id, engine and country as long as query ids have no colons.
Extract sightings
import re
from urllib.parse import urlsplit
def host(url):
return (urlsplit(url or "").hostname or "").removeprefix("www.").lower()
def money(value):
if isinstance(value, dict):
return value.get("raw"), value.get("value")
m = re.search(r"\d[\d.,]*", value or "")
return value, float(m.group(0).replace(",", "")) if m else None
def extract(engine, r):
"""Yields (channel, actor_kind, actor, position, title, url, price) from one result."""
if engine == "GOOGLE":
for a in r.get("ads", []):
if a.get("type") == "SHOPPING_CARD":
yield "shopping_ad", "store", (a.get("store") or "").lower(), a.get("position"), a.get("title"), None, a.get("price")
else:
yield "search_ad", "domain", host(a.get("url")), a.get("position"), a.get("title"), a.get("url"), None
for o in r.get("organicResults", [])[:10]:
yield "organic", "domain", host(o["link"]), o.get("position"), o.get("title"), o["link"], None
for c in r.get("shoppingCards", []):
yield "shopping_card", "store", (c.get("store") or "").lower(), c.get("position"), c.get("title"), None, c.get("price")
for a in (r.get("aioverview") or {}).get("ads", []):
if a.get("type") == "SHOPPING":
yield "aio_ad", "store", (a.get("store") or "").lower(), a.get("position"), a.get("title"), None, a.get("price")
else:
yield "aio_ad", "domain", host(a.get("url")), a.get("position"), a.get("title"), a.get("url"), None
elif engine == "CHATGPT":
for p in (item["product"] for item in r.get("inlineProducts", [])):
for o in p.get("offers", []):
yield "ai_offer", "merchant", (o.get("merchant_name") or "").lower(), p.get("position"), p.get("title"), o.get("url"), o.get("price")
for s in r.get("sources", []):
yield "ai_source", "domain", host(s["url"]), s.get("position"), s.get("label"), s["url"], None
elif engine == "AIMODE":
for c in r.get("shoppingCards", []) + r.get("inlineProducts", []):
yield "ai_offer", "store", (c.get("store") or "").lower(), c.get("position"), c.get("title"), c.get("productLink") or None, c.get("price")
for a in (r.get("ads") or {}).get("ads", []):
yield "ai_ad", "store", (a.get("store") or "").lower(), a.get("position"), a.get("title"), a.get("url"), a.get("price")
for s in r.get("sources", []):
yield "ai_source", "domain", host(s["url"]), s.get("position"), s.get("label"), s["url"], None
The ChatGPT branch reads inlineProducts offers and sources only: its ads and shoppingCards lists come back empty on the answers served today.
Classify, open cases, detect lookalikes
apply runs on each verified, non-test delivery:
BRAND = "acme"
CONFUSABLE = str.maketrans({"0": "o", "1": "l", "3": "e", "5": "s"})
def allowlisted(db, kind, actor):
if kind == "domain":
parts = actor.split(".")
candidates = {".".join(parts[i:]) for i in range(len(parts) - 1)}
return bool(db.execute("select 1 from allowlist where kind = 'domain' and value = any(%s)",
(list(candidates),)).fetchone())
return bool(db.execute("select 1 from allowlist where kind = %s and value = %s", (kind, actor)).fetchone())
def lookalike(domain):
label = domain.split(".")[0].translate(CONFUSABLE).replace("-", "")
return BRAND in label
def apply(db, d):
task = d["task"]
with db.transaction():
row = db.execute("update checks set status = %s, completed_at = now() "
"where task_id = %s and status not in ('COMPLETED','FAILED') returning engine",
(task["status"], task["id"])).fetchone()
if not row or task["status"] != "COMPLETED":
return
for channel, kind, actor, position, title, url, price in extract(row[0], d["response"]["result"]):
if not actor or allowlisted(db, kind, actor):
continue
if channel in ("organic", "ai_source") and not lookalike(actor):
continue # unknown but not brand-like: not a candidate on these channels
price_raw, price_value = money(price) if price else (None, None)
db.execute("insert into sightings (task_id, channel, actor_kind, actor, position, title, url, price_raw, "
"price_value) values (%s,%s,%s,%s,%s,%s,%s,%s,%s) on conflict do nothing",
(task["id"], channel, kind, actor, position, title, url, price_raw, price_value))
db.execute("""insert into cases (channel, actor_kind, actor, first_seen, last_seen)
values (%s,%s,%s,now(),now())
on conflict (channel, actor_kind, actor) do update
set last_seen = now(), sightings = cases.sightings + 1,
status = case when cases.status = 'removed' then 'recurred' else cases.status end""",
(channel, kind, actor))
Replace the CONFUSABLE table with a full confusables mapping (Unicode publishes one), and add edit-distance checks if typosquats such as acmme matter for your brand. Organic results and AI sources produce a candidate only when the domain looks like the brand; ads, shopping cards and offers on branded queries produce a candidate for any non-allowlisted actor.
Metrics
-- Unauthorised ad rate per country, last 7 days
select c.country,
avg((exists (select 1 from sightings s
where s.task_id = c.task_id and s.channel in ('search_ad','shopping_ad')))::int) as unauthorised_ad_rate,
count(*) as checks
from checks c
where c.engine = 'GOOGLE' and c.status = 'COMPLETED' and c.completed_at > now() - interval '7 days'
group by c.country;
-- Open cases by channel with persistence
select channel, count(*) as open_cases,
percentile_cont(0.5) within group (order by extract(epoch from last_seen - first_seen) / 86400) as median_days
from cases where status in ('open','reported','recurred')
group by channel;
-- Price anomalies: offers under 60% of list price for tracked products (list_prices(title_pattern, price))
select s.channel, s.actor, s.title, s.price_raw, lp.price as list_price, c.completed_at
from sightings s join checks c using (task_id)
join list_prices lp on s.title ilike lp.title_pattern
where s.price_value < 0.6 * lp.price and c.completed_at > now() - interval '1 day';
Unauthorized seller share needs allowlisted sightings too. If you report it, store every sighting and classify in the query instead of filtering in apply.
Cost planning
Price per request at the Starter tier: Google Search $0.00103 for one page, plus $0.00071 with include.aioverview; ChatGPT $0.0006; AI Mode $0.00206. Failed tasks are not charged. Higher tiers, reached by 30-day spend, cost less. See balance and pricing.
Worked example: a consumer brand selling in 2 markets.
| Workload | Tasks per day | Cost per task | Cost per day |
|---|---|---|---|
| 15 high-tier branded queries × 2 markets × 4 checks, with AI Overview | 120 | $0.00174 | $0.2088 |
| 40 product queries × 2 markets, daily, with AI Overview | 80 | $0.00174 | $0.1392 |
20 shopping prompts on ChatGPT in the US with shopping |
20 | $0.0006 | $0.012 |
| Same 20 prompts × 2 markets on AI Mode | 40 | $0.00206 | $0.0824 |
| Total | 260 | $0.4424 |
That is $13.27 over 30 days. Dropping the AI Overview from product queries saves 80 × $0.00071 × 30 = $1.70. Tiers are on pricing.
Pitfalls
Auto-actioning candidates. Resellers, marketplaces, price-comparison sites and fan communities are frequently legitimate. Review every new case before enforcement.
Allowlist drift. Authorized retailers change store display names. When a case turns out to be an authorized seller under a new name, add the name to the allowlist instead of only dismissing the case.
Grouping shopping ads by URL. Sponsored shopping cards use Google aclk? redirect URLs. Group by store.
Empty product links. Google shoppingCards[].productLink is usually an empty string. Identify sellers by store, not link.
Price strings. Google and AI Mode prices are objects whose value and currency can be null; raw is always the displayed text. ChatGPT offer prices are strings. Parse carefully and never compare currencies without converting.
Single-sample conclusions. An advertiser missing from one check may still be active. Use presence rates over several checks before closing a case as removed.
Market blind spots. Abuse can target a single country. Cover every market you sell in for high-tier queries.
Which engines fit
| Engine | Brand protection use |
|---|---|
| Google Search | Branded ads (text and shopping), organic lookalikes, shopping cards, AI Overview ads |
| ChatGPT | Cited sources for “is this store legit” prompts; merchant offers in shopping cards when ChatGPT serves them (none on today’s answers) |
| AI Mode | Shopping cards, inline products and sponsored ads inside Google’s conversational results |
ChatGPT shopping cards and tracking ads in AI answers describe those response fields in more depth. The Google Search engine page lists every request field.
Questions
Can this detect who is bidding on my brand name?
It shows which ads appeared on the result page for a branded query at the time of the request. Google Search responses include result.ads with type RESULT for text ads (title, url, displayedUrl, domain, description, blockPosition) and type SHOPPING_CARD for sponsored product cards (store, price). Compare advertisers with your allowlist.
Does the API take down infringing listings or ads?
No. It collects what public result pages and AI answers showed. Enforcement, such as trademark complaints to ad platforms, marketplace reports or legal action, is a separate process your team or a vendor runs.
Where do sellers appear in AI answers?
On ChatGPT, inlineProducts (include.shopping) carries the products from its shopping search, with price, link and merchant offers; shoppingCards and ads come back empty on the answers served today, and sources show which sites it cites. AI Mode returns shoppingCards and inlineProducts with store and price, and an ads section. Google's AI Overview can carry its own ads.
How often should branded queries be checked?
Ads and shopping results can differ between searches, so check high-risk branded queries several times a day, and organic lookalike checks daily. Each check is one task, so frequency is a cost decision.
Is a seller that is not on my allowlist always infringing?
No. Resellers, marketplaces, comparison sites and second-hand sellers can be legitimate. The pipeline flags candidates; a person decides whether a case exists before any action.
More use cases
AI Visibility API: brand mentions and citations in AI
The GEO measurement layer: how ChatGPT, Gemini and Copilot describe a brand, with mentions, citations and the wording users see.
Rank Tracking API
Google positions, SERP features and the AI Overview for any keyword, as structured JSON from one call.
Local Rank Tracking API
AI answers and Google results per market as parsed JSON: city-level on AI Mode today, US-state targeting coming soon.