AnswerLine Start free

Use cases

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

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}}'

Sync or queue it with POST /v1/async/task. Full field list on the ChatGPT page and in the API reference.

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

  1. 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”.
  2. Product queries: flagship product names with and without the brand, where counterfeit sellers compete.
  3. 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?”.
  4. Markets: run each query in every market you sell in (country).

Allowlist

The classifier is only as good as the allowlist. Maintain:

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