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
- The full answer text and markdown, as the engine returned it
- Every cited source with its position, for citation share across domains
- The search queries the engine ran, where it returns them, showing which keywords feed an answer
- Market targeting, because the same prompt is answered differently by market
- Async batches and webhooks, so a weekly prompt set runs unattended
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}}'
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.
- Engines: ChatGPT, Gemini, Perplexity, Copilot, Grok, Google AI Mode and Google Search with its AI Overview, each with its own endpoint and response schema, and Google News on the same API.
- Export: every answer is raw JSON, returned by a synchronous call, by
GET /v1/async/task/{taskId}, or in a signed webhook delivery, with up to 500 tasks per batch. There is no separate export step: you store the rows and compute metrics in your own database. Results are kept for 4 days after the request (privacy policy). - Your own product: the terms of service let you use the results for internal or commercial purposes, subject to the acceptable-use rules and the rights of the engines queried, so a GEO product or client reporting built on them is intended use. Reselling raw access to the API needs a written agreement, and results may not be misrepresented as coming directly from a third-party service.
- Trying it: every new account gets 100 free requests, once, no card, usable on every live engine, and the cost planning section sizes a pilot on the AI engines.
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.
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.
- 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.
- 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.
- Give every prompt a stable id. Rewording a prompt makes it a new prompt, so retire the old id instead of editing it.
- Fix the markets.
countryis 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 USstate; ChatGPT, Copilot, Perplexity and Grok answer at country level. - 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:
- The body is the same JSON as
GET /v1/async/task/{id}:task,creditsandresponse. A completed task’s engine output isresponse.result. - Every delivery is signed in
Webhook-Signature. Verify the raw bytes before parsing. - Deliveries can arrive out of order and more than once. Deduplicate by
task.id, which is inside the signed body. - Your endpoint must answer 2xx within 15 seconds, or the delivery is retried, up to 10 attempts.
- Dashboard test deliveries carry
"test": true. Ignore them.
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:
- Lost citation: the brand domain was in
citationslast run and is not now. - New competitor: a tracked competitor appears in
mentionsfor a prompt where it was absent. - Volatility spike: the Jaccard distance of cited domains exceeds a threshold set from the prompt’s own history, not a global constant.
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.
- Per prompt: $0.0006 + $0.0006 + $0.00274 + $0.00206 = $0.006
- Per run: 120 × $0.006 = $0.72
- Per month at 4 runs: $2.88; in a month with 5 weekly runs, $3.60
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
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.
Competitor Analysis API
See which competitors ChatGPT, Gemini and Copilot recommend and cite for any prompt set, as JSON.