AnswerLine Start free

Use cases

AI Visibility API: brand mentions and citations in AI

Whether a brand is named, recommended or cited in an AI answer is measurable. It takes the raw answers, collected the same way on every run, across enough prompts and markets for the rates to hold.

Who it's for

Teams building AI-visibility and GEO products, agencies reporting on AI presence for clients, and in-house marketers who want the data without building a collection pipeline.

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":"What are the best running shoes for flat feet?","country":"US","include":{"markdown":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 an AI visibility API is

An AI visibility API sends a prompt to an AI assistant or AI search feature in a chosen market and returns the answer as structured JSON: the answer text, the sources it cited (each with position, url and label), and the inline citationPills. Brand metrics are arithmetic over those rows. It is also called a GEO (generative engine optimization) API.

What it does not do: choose your prompts, decide which strings count as your brand, explain why one page was cited instead of another, or change what an engine says. Those stay with you. Its job is to return the same shape of record for every prompt, engine and market, so the comparison across them holds.

What you are measuring

When a buyer asks an assistant about your category, does the answer name you, link to you, and rank you above alternatives? The raw material is the answer: its text and its cited sources. The metrics are arithmetic over a well-designed sample of those answers, and their definitions decide the schema, so fix them first.

One prompt set, each engine, one set of rates A prompt set is expanded by engine, market and run into one task each. The engines and their price per request are ChatGPT $0.0006, Gemini $0.0006, Copilot $0.00274, AI Mode $0.00206 and Google Search with the AI Overview $0.00174. Every answer becomes one row, and the period metrics are computed from those rows: mention rate, citation rate, citation share, share of voice, mean cited position, first-mention order and answer volatility, grouped by engine, market and period. engines, price per request ChatGPT Gemini Copilot AI Mode Google + AI Overview metrics for the period one row per answer mention rate citation rate citation share share of voice mean cited position first-mention order answer volatility grouped by engine, market and period $0.0006 $0.0006 $0.00274 $0.00206 $0.00174 prompt set each prompt × engine × market × run

Every metric on this page is arithmetic over the answer rows, so the sample frame on the left, not the query, is what decides whether a number means anything.

Metric Definition Computed over
Mention rate Answers whose text names the brand (any alias) ÷ answers collected Prompt set × engine × market × period
Citation rate Answers with at least one source on a brand domain ÷ answers collected Same
Citation share Sources on brand domains ÷ all sources across the answers Same
Share of voice Brand mentions ÷ mentions of every brand in your tracked competitor set Same
Mean cited position Average of the best (lowest) position of a brand-domain source, over answers that cite the brand Same
First-mention order Rank of the brand among tracked brands by first character offset in the answer text Per answer, then averaged
Answer volatility 1 − Jaccard similarity of the cited-domain sets for the same prompt in consecutive runs Per prompt, then averaged

Mention and citation are independent signals: an engine can recommend a brand without linking to it, and it can cite a brand’s blog post while recommending a competitor. Every rate needs a denominator of answers collected, not tasks submitted, because a failed task is missing data rather than a zero.

Mentions vs citations and the share-of-voice framework cover both metrics in more depth.

Architecture

A prompt set, a scheduler, a submitter, a webhook receiver, a store, and a metrics job:

prompt set ──> scheduler ──> POST /v1/async/task/batch ──> queue
                                                            │
metrics job <── Postgres <── webhook receiver <── task finishes
                   ▲
                   └── reconciler (GET /v1/async/task/{id} for anything missing)

This guide carries the complete submitter, receiver and reconciler. The other use-case guides reuse the same pattern and show only what differs.

Prompt set design

The prompt set is your sample frame. If it is biased, every metric is biased.

  1. Group prompts by intent: category discovery (“best CRM for a 10-person agency”), comparison (“HubSpot vs Pipedrive for agencies”), problem-led (“how do I stop losing leads between sales calls”) and brand-direct (“is Acme CRM good for agencies”). Report metrics per group; a blended number lets strong brand-direct results hide weak discovery results.
  2. Write prompts the way buyers type them: full sentences with constraints such as team size, budget and region. Only the brand-direct group should name your brand.
  3. Give every prompt a stable id. Rewording a prompt makes it a new prompt, so retire the old id instead of editing it.
  4. Fix the markets. country is required for ChatGPT and Gemini, which answer from 213 countries, as do AI Mode and Google Search; Copilot, Perplexity and Grok take any country. Gemini also takes a US state; ChatGPT, Copilot, Perplexity and Grok answer at country level.
  5. Size the set for the decision. A rate near 50% measured over 40 answers has a 95% confidence interval of about ±15 percentage points; halving that width takes four times the answers, from more prompts or repeat samples. See prompt set design.

Schedule

Weekly suits most AI answer tracking; run daily for a launch, a crisis, or a short list of high-value prompts. The unit of work is one prompt, on one engine, in one market, in one run. That tuple is the task’s identity, so it becomes the idempotencyKey:

{prompt_id}:{engine}:{country}:{run_date}:{sample}

Keys are unique across your account. Re-running the submitter after a crash or timeout creates nothing twice: existing tasks come back as RESOURCE_ALREADY_EXISTS inside the batch response.

Async batches and webhooks

Submit with POST /v1/async/task/batch, 1 to 500 tasks per call, each with taskType, payload, idempotencyKey and webhook.url. Tasks cost the engine’s base price, wait in your queue until a worker picks them up, and the result is POSTed to your URL when the task finishes. The receiver depends on five properties of deliveries, all documented in async tasks and webhooks:

Storage schema

create table prompts (
  prompt_id   text primary key,
  intent      text not null,          -- discovery | comparison | problem | brand
  prompt      text not null,
  retired_at  timestamptz
);

create table tasks (
  task_id          uuid primary key,
  idempotency_key  text unique not null,
  prompt_id        text not null references prompts,
  engine           text not null,     -- CHATGPT, GEMINI ...
  country          text not null,
  run_date         date not null,
  sample           int  not null default 0,
  status           text not null default 'QUEUED',
  credits_charged  numeric,
  finished_at      timestamptz
);

create table answers (
  task_id      uuid primary key references tasks,
  text         text not null
);

create table citations (
  task_id   uuid references answers,
  position  int,
  url       text not null,
  domain    text not null,
  inline    boolean not null,        -- true when it also appears in citationPills
  primary key (task_id, url)
);

create table mentions (
  task_id       uuid references answers,
  brand         text not null,
  first_offset  int  not null,
  primary key (task_id, brand)
);

answers.task_id as primary key is the deduplication: a second delivery of the same task hits the conflict and does nothing. Keep the raw response in object storage if you want to re-parse later with better brand matching.

Change detection

Compare each run with the previous run of the same prompt, engine and market:

Alert only when a change persists for two consecutive runs, or when repeat samples within one run agree. Single-run flips are frequently sampling noise; see AI answer volatility.

End-to-end Python

requests, flask and psycopg over plain HTTP, with API_KEY, WEBHOOK_SECRET and DATABASE_URL in the environment.

Submit a run

import datetime as dt
import os
import requests

API = "https://api.answerline.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['API_KEY']}"}
WEBHOOK_URL = "https://hooks.example.com/answers"

ENGINES = {
    "CHATGPT": lambda p, c: {"prompt": p, "country": c},
    "GEMINI": lambda p, c: {"prompt": p, "country": c},
    "COPILOT": lambda p, c: {"prompt": p, "country": c},
    "AIMODE": lambda p, c: {"prompt": p, "country": c},
}

def build_tasks(prompts, countries, run_date, samples=1):
    for prompt_id, prompt in prompts:
        for engine, payload in ENGINES.items():
            for country in countries:
                for s in range(samples):
                    yield {
                        "taskType": engine,
                        "payload": payload(prompt, country),
                        "idempotencyKey": f"{prompt_id}:{engine}:{country}:{run_date}:{s}",
                        "webhook": {"url": WEBHOOK_URL},
                    }

def submit(db, prompts, countries, run_date=None):
    run_date = run_date or dt.date.today().isoformat()
    tasks = list(build_tasks(prompts, countries, run_date))
    for start in range(0, len(tasks), 500):
        chunk = tasks[start:start + 500]
        res = requests.post(f"{API}/v1/async/task/batch", json=chunk, headers=HEADERS, timeout=60)
        res.raise_for_status()  # a 429 QUEUE_LIMIT_EXCEEDED rejects the whole chunk: retry later
        for item in res.json()["results"]:
            sent = chunk[item["index"]]
            if not item["success"]:
                if item["error"]["code"] != "RESOURCE_ALREADY_EXISTS":
                    print("not queued", sent["idempotencyKey"], item["error"]["code"])
                continue
            prompt_id, engine, country, day, sample = sent["idempotencyKey"].split(":")
            db.execute(
                """insert into tasks (task_id, idempotency_key, prompt_id, engine, country, run_date, sample)
                   values (%s, %s, %s, %s, %s, %s, %s) on conflict do nothing""",
                (item["task"]["id"], sent["idempotencyKey"], prompt_id, engine, country, day, int(sample)),
            )
    db.commit()

Prompt ids must not contain : for the key split to work; use slugs.

Receive the webhook

import hashlib, hmac, json, re, time
from urllib.parse import urlsplit
from flask import Flask, abort, request
import psycopg

app = Flask(__name__)
db = psycopg.connect(os.environ["DATABASE_URL"])

def verify(raw: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
    parts = [p.split("=", 1) for p in (header or "").split(",") if "=" in p]
    ts = [v for k, v in parts if k == "t"]
    sigs = [v for k, v in parts if k == "v1"]
    if len(ts) != 1 or not sigs or not ts[0].isdigit() or abs(time.time() - int(ts[0])) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{ts[0]}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)

@app.post("/answers")
def answers():
    raw = request.get_data()
    if not verify(raw, request.headers.get("Webhook-Signature"), os.environ["WEBHOOK_SECRET"]):
        abort(400)
    delivery = json.loads(raw)
    if delivery.get("test"):
        return "", 204
    store(delivery)
    return "", 204

The Python and TypeScript SDKs ship the same check as verify_webhook and verifyWebhook.

Parse the fields

Each engine puts the answer in response.result.

BRANDS = {
    "acme": re.compile(r"\bacme(\s+crm)?\b", re.I),
    "pipedrive": re.compile(r"\bpipedrive\b", re.I),
    "hubspot": re.compile(r"\bhubspot\b", re.I),
}

def extract(engine: str, result: dict):
    """Returns (text, sources, inline_urls) for one engine's result."""
    return (result.get("text", ""), result.get("sources", []),
            {p["url"] for p in result.get("citationPills", [])})

def domain(url: str) -> str:
    host = urlsplit(url).hostname or ""
    return host.removeprefix("www.")

def store(delivery: dict):
    task = delivery["task"]
    with db.transaction():
        db.execute(
            "update tasks set status = %s, credits_charged = %s, finished_at = now() where task_id = %s",
            (task["status"], delivery["credits"]["creditsCharged"], task["id"]),
        )
        if task["status"] != "COMPLETED":
            return
        text, sources, inline = extract(task["taskType"], delivery["response"]["result"])
        inserted = db.execute(
            "insert into answers (task_id, text) values (%s, %s) on conflict do nothing",
            (task["id"], text),
        ).rowcount
        if not inserted:
            return  # duplicate delivery
        for s in sources:
            db.execute(
                "insert into citations values (%s, %s, %s, %s, %s) on conflict do nothing",
                (task["id"], s.get("position"), s["url"], domain(s["url"]), s["url"] in inline),
            )
        for brand, pattern in BRANDS.items():
            if m := pattern.search(text):
                db.execute("insert into mentions values (%s, %s, %s)", (task["id"], brand, m.start()))

Compute the metrics

-- Mention rate, citation rate and citation share per engine and market for one run
with a as (
  select t.task_id, t.engine, t.country
  from tasks t join answers using (task_id)
  where t.run_date = '2026-09-14'
)
select
  a.engine, a.country,
  count(*)                                                              as answers,
  avg((exists (select 1 from mentions m
               where m.task_id = a.task_id and m.brand = 'acme'))::int)  as mention_rate,
  avg((exists (select 1 from citations c
               where c.task_id = a.task_id and c.domain = 'acme.com'))::int) as citation_rate,
  (select count(*) from citations c join a a2 using (task_id)
     where a2.engine = a.engine and a2.country = a.country and c.domain = 'acme.com')::numeric
  / nullif((select count(*) from citations c join a a2 using (task_id)
     where a2.engine = a.engine and a2.country = a.country), 0)          as citation_share
from a
group by a.engine, a.country;

Share of voice over the tracked set:

select t.engine, m.brand,
       count(*)::numeric / sum(count(*)) over (partition by t.engine) as share_of_voice
from mentions m join tasks t using (task_id)
where t.run_date = '2026-09-14'
group by t.engine, m.brand;

Reconcile

A failing delivery gets its last retry about 21 to 43 minutes after its first attempt. If your receiver was down longer, some results never arrive. Once a run should be finished, fetch the stragglers:

def reconcile(db, run_date):
    rows = db.execute(
        "select task_id from tasks where run_date = %s and status in ('QUEUED', 'PROCESSING')", (run_date,)
    ).fetchall()
    for (task_id,) in rows:
        res = requests.get(f"{API}/v1/async/task/{task_id}", headers=HEADERS, timeout=30)
        res.raise_for_status()
        body = res.json()
        if body["task"]["status"] in ("COMPLETED", "FAILED"):
            store(body)

store accepts this body unchanged because a delivery and a task fetch have the same shape. Treat only COMPLETED and FAILED as final; async tasks explains why a task can show PROCESSING and then QUEUED again.

Cost planning

Price per request at the Starter tier; higher tiers, reached by 30-day spend, cost less (the full rules are in balance and pricing):

Engine and options Price per task
ChatGPT $0.0006
Gemini $0.0006
Perplexity $0.0015
Copilot $0.00274
Grok $0.00218
AI Mode $0.00206
Google Search, 1 page, with the AI Overview $0.00103 + $0.00071 = $0.00174

Worked example: 120 prompts, in the US, weekly, one sample each, on ChatGPT, Gemini, Copilot and AI Mode.

Doubling samples on the 30 most volatile prompts adds 30 × $0.006 × 4 = $0.72 a month.

A pilot of 25 prompts on ChatGPT, one market, weekly, uses 25 × 5 = 125 tasks, $0.075 a month, more than the 100 free requests a new account gets, so validate on a smaller prompt set and run the pilot on a topped-up balance. Every task reserves its maximum cost when accepted, so leave headroom. Tiers are on pricing; AI monitoring cost planning has more scenarios.

Pitfalls

Treating one answer as the truth. Generative answers vary between identical runs. Report rates over a prompt set, show sample sizes next to every percentage, and use repeat samples before alerting on a single prompt.

Loose brand matching. “Acme” matches unrelated companies, and short brand names match common words. Use word boundaries, a list of aliases and product names, and a small exclusion list. Match on text rather than markdown so link syntax does not create false matches, and store the raw answer so you can re-run matching later.

Counting answers without search. ChatGPT decides on its own whether to search the web; disableWebSearch changes nothing today. An answer with no sources showed no search, so compute citation metrics over answers with sources and report the share without. provenance.webSearchTriggered is true when the answer cites sources.

Counting redirects as domains. Google-family results may carry redirectLink beside the destination. Compute domains from url, which holds the destination.

Reading missing AI Overviews as losses. result.aioverview is null when Google showed none. Track AI Overview presence as its own rate and compute citation metrics only over queries that had one.

Silent gaps. A task that has not started within 4 hours fails with QUEUE_WAIT_EXCEEDED, uncharged; resubmit it. Batch items can fail with INSUFFICIENT_CREDITS after earlier items in the same batch were queued. Log every non-success item and run the reconciler before computing a run’s metrics.

Mixing prompt versions. Editing a prompt’s wording breaks its time series. Retire and replace.

Which engines fit

Engine Why include it Watch for
ChatGPT citationPills separates inline citations from the source list searchQueries comes back empty on the answers served today; ChatGPT decides whether to search, so some answers carry no sources
Gemini Google’s assistant with inline citationPills Separate audience from AI Mode; track both if Google matters
Copilot Microsoft’s Bing-grounded assistant with the sources it cites and inline citationPills searchQueries, shoppingCards and map are coming
AI Mode Google’s conversational results; takes location for city-level markets No add-ons
Google Search The AI Overview plus the organic results beneath it, in one call aioverview is null when Google showed none
Perplexity sources and inline citationPills per answer; related_queries for keyword insight Returns no search_model_queries today
Grok searchQueries lists the web and X searches it ran Targeted by country only; state is refused

Start with ChatGPT, Gemini, Copilot and AI Mode, add Google Search with the AI Overview where Google matters, add Perplexity and Grok where your buyers use them, and extend to more of the 213 markets as you grow. The quickstart gets the first task running.

Related: what GEO is and measuring brand mentions per engine.

Questions

What is an AI visibility API?

An API that sends a prompt to an AI assistant or AI search feature in a chosen market and returns the answer as structured JSON: the answer text, the sources it cited with their positions, and its inline citations. Brand metrics such as mention rate, citation share and share of voice are arithmetic over those answers. It is the collection layer people also call a GEO API; it measures what engines say and does not change it.

Which engines does it cover?

ChatGPT, Gemini, Perplexity, Copilot, Grok, Google AI Mode and Google Search with its AI Overview, each on its own endpoint with its own response schema. ChatGPT, Gemini, AI Mode and Google Search answer from 213 countries, and Copilot, Perplexity and Grok from any country. Google News is available on the same API for news coverage.

Can I export AI visibility data through the API?

Yes. Every answer is returned as raw JSON: in the response to a synchronous call, from GET /v1/async/task/{taskId}, or as a signed webhook delivery when an async task finishes, with up to 500 tasks per batch. You store the rows and compute the metrics in your own database. Results are kept for 4 days after the request, so store them within that window.

Can I try it free?

Every new account gets 100 free requests, granted once, no card. They work on every live engine, the AI engines included, a whole request each.

Can I build a GEO or AI visibility product on it and sell it to my customers?

Yes. The terms of service let you use the results for internal or commercial purposes. What they restrict is reselling raw access to the API without a written agreement, and misrepresenting results as coming directly from a third-party service; complying with the engines' own terms on how their content is used is your responsibility.

What is the difference between mention rate and citation rate?

Mention rate is the share of collected answers whose text names your brand. Citation rate is the share of answers that list a URL on one of your domains among their sources. A brand can be mentioned without being cited and cited without being mentioned, so track both.

How much does a weekly AI visibility run cost?

Multiply prompts by markets by the per-task price of each engine. A ChatGPT task costs $0.0006, a Copilot task $0.00274, a Gemini task $0.0006, an AI Mode task $0.00206, and a Google Search task with the AI Overview $0.00174.

More use cases