AI share of voice: a measurement framework
AI share of voice is the portion of AI answers, citations and recommendations in a defined prompt set that belong to your brand, measured per engine and market over repeated runs. A single “AI visibility” score hides the parts you can act on. A small set of rates computed from structured answer data keeps them separate: whether you are named, whether your pages are the evidence, whether you are in the recommendation list, where you appear in it, and whether you rank for the searches the assistant runs to find sources.
The unit of observation
Every metric is built from runs. A run is one answer for one cell: a prompt, a market and an engine. From each run you extract observations:
| Observation | Question | Where it comes from |
|---|---|---|
| Mentioned | Does the answer name the brand? | result.text on all engines |
| Cited | Is one of your URLs a source? At which position? | result.sources[] {position, url} |
| Recommended | Is the brand in the recommendation or product set? | Shopping and product fields per engine; brand names in text |
| Answer rank | Where in the list of named brands? | First-mention order in text |
| Fan-out queries | Which searches did the assistant run? | searchQueries[] (ChatGPT’s is empty on the answers served today) |
The mentions vs. citations post explains why “named” and “cited” diverge and need separate tracking.
Field names differ per engine
Code that assumes one engine’s shape breaks on the others. Optional arrays are omitted when empty, so always read them with a default.
| Engine | Entities | Product and shopping fields | Fan-out queries |
|---|---|---|---|
| ChatGPT | entities[] {type, name} |
entities[] with type product; inlineProducts[].product.title with include.shopping (shoppingCards[] comes back empty today) |
searchQueries[], free with include.searchQueries, empty on the answers served today |
| Perplexity | none | none | none |
| Copilot | none | coming | coming |
| Grok | none | none | searchQueries[] |
| Gemini | none | none (places[] for local) |
none |
| Google AI Mode | none | shoppingCards[].title, inlineProducts[].title |
none |
Perplexity, Gemini and AI Mode give no fan-out queries and ChatGPT’s list comes back empty today, so fan-out coverage is computed from the engines that return them.
The metrics
Mention rate
The share of runs in which the brand is named.
mention_rate(cell) = runs where brand mentioned / runs
Compute it per cell, then aggregate by engine, market or prompt cluster (category, comparison, problem). A single blended number across engines hides which engine moved.
Mention share
Your mentions relative to all tracked brands’ mentions in the same runs.
mention_share = your mentions / Σ mentions of all tracked brands
This is the metric closest to classic share of voice, and it depends on the competitor list: define that list once and keep it stable across periods.
Citation share
Your share of the source entries across the prompt set.
citation_share = sources[] entries on your domains / all sources[] entries
It measures how much of the evidence layer you own. Track mean citation position alongside it; being source 1 and source 9 are different outcomes.
Recommendation share
For commercial prompts, the share of runs in which you appear in the recommendation set: the product fields in the table above where the engine returns products, and brand detection in text on every engine. Report it by engine, because the underlying signal differs.
Answer rank
When the answer is a list, your position among named brands: the order of first occurrence of each tracked brand name in text, on every engine. Report the mean rank over runs where you appear, together with the recommendation share, since a good rank on rare appearances is not a strong position.
Fan-out coverage
The share of the assistant’s own searches for which your domain ranks in Google Search.
fanout_coverage = fan-out queries where your domain is in organicResults / fan-out queries
Assistants that search the web choose sources from what those searches return. If you do not rank for the searches, you are unlikely to be cited from them. The query fan-out post and how engines choose citations go deeper.
To compute it:
- Collect fan-out queries from the engines that return them (table above). ChatGPT’s
searchQueries[]is free withinclude.searchQueries: true, but it comes back empty on the answers served today. - Deduplicate per market (normalize case and whitespace).
- Run each query on Google Search with the same
country:POST /v1/monitor/googlesynchronously, or aGOOGLEasync task ($0.00103 for the first page on every tier). - For each query, record the best
positioninorganicResults[]whoselinkis on your domain, or none. - Coverage is the share of queries with a rank, optionally split into top 3 and top 10.
Uncertainty and sample size
Answers vary run to run, so every rate is an estimate. For a rate p over n independent runs:
SE = sqrt( p × (1 − p) / n ) 95% margin ≈ 1.96 × SE
n needed for margin E = p × (1 − p) × (1.96 / E)²
Computed examples:
- p = 0.4, n = 50: SE = sqrt(0.24 / 50) = 0.069, margin ±13.6 points.
- p = 0.5, target E = 0.10: n = 0.25 × 384.2 = 96.0, so 97 runs.
- p = 0.5, target E = 0.05: n = 0.25 × 1,536.6 = 384.2, so 385 runs.
Decide the margin your reporting needs, then size runs per cell, or pool cells when a coarser number is acceptable. For a set-level rate across many prompts, the prompts themselves differ, so use the spread of per-prompt rates: SE = sd(prompt rates) / sqrt(prompts). For citation share, sources within one answer are correlated, so treat the answer, not the source entry, as the independent unit when estimating its uncertainty. The volatility post covers intervals near 0% and 100% and comparisons between periods.
Implementing it
The pipeline is one scheduled batch submission and one webhook receiver that turns each finished task into a row. The examples use requests and Flask over HTTP.
1. Submit the batch
Encode what each task measures in its idempotencyKey, so a retried submission creates nothing twice and the receiver can recover the prompt and market from the delivery.
import datetime, os, requests
API = "https://api.answerline.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['API_KEY']}", "Content-Type": "application/json"}
WEBHOOK = "https://example.com/hooks/answers"
PROMPTS = {"p001": "best crm for small agencies", "p002": "hubspot vs pipedrive for agencies"}
MARKETS = ["US"]
ENGINES = {
"CHATGPT": {"include": {"searchQueries": True, "shopping": True}},
"GEMINI": {},
"PERPLEXITY": {},
"COPILOT": {},
"GROK": {},
}
def submit(run_date, run_index):
tasks = []
for pid, prompt in PROMPTS.items():
for country in MARKETS:
for task_type, extra in ENGINES.items():
tasks.append({
"taskType": task_type,
"idempotencyKey": f"sov:{run_date}:{run_index}:{task_type}:{country}:{pid}",
"payload": {"prompt": prompt, "country": country, **extra},
"webhook": {"url": WEBHOOK},
})
for start in range(0, len(tasks), 500): # a batch holds 1-500 tasks
r = requests.post(f"{API}/v1/async/task/batch", json=tasks[start:start + 500], headers=HEADERS, timeout=30)
r.raise_for_status()
for item in r.json()["results"]:
if not item["success"] and item["error"]["code"] != "RESOURCE_ALREADY_EXISTS":
print(tasks[start + item["index"]]["idempotencyKey"], item["error"]["code"])
submit(datetime.date.today().isoformat(), 1)
Cost for this example per run slot at the Starter tier: 2 prompts × 1 market × (ChatGPT $0.0006 + Gemini $0.0006) = 2 × $0.0012 = $0.0024; searchQueries is free. Multiply by runs per period for the budget; cost planning covers monthly totals and tiers.
2. Receive, verify and extract rows
The delivery body is the finished task JSON. Verify the signature on the raw bytes, ignore test deliveries, deduplicate by task.id, and read the engine result at response.result.
import hashlib, hmac, json, os, re, sqlite3, time
from urllib.parse import urlparse
from flask import Flask, abort, request
SECRET = os.environ["WEBHOOK_SECRET"].encode()
BRAND, DOMAIN = "Acme CRM", "acmecrm.com"
TRACKED = ["Acme CRM", "HubSpot", "Pipedrive", "Salesforce"]
db = sqlite3.connect("sov.db", check_same_thread=False)
db.execute("""create table if not exists runs (task_id text primary key, run_date text, engine text,
country text, prompt_id text, mentioned int, cited_position int, recommended int, answer_rank int,
source_domains text, fanout text)""")
def verify(raw, header):
t, sigs = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t":
t = value
elif key == "v1":
sigs.append(value)
if not t or not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
def on_domain(url):
host = (urlparse(url or "").hostname or "").lower()
return host == DOMAIN or host.endswith("." + DOMAIN)
def product_names(engine, r):
if engine == "CHATGPT":
return ([e.get("name", "") for e in r.get("entities", []) if e.get("type") == "product"]
+ [i.get("product", {}).get("title", "") for i in r.get("inlineProducts", [])])
if engine == "COPILOT":
return [p.get("name", "") for c in r.get("shoppingCards", []) for p in c.get("products", [])]
if engine == "AIMODE":
return [c.get("title", "") for c in r.get("shoppingCards", [])] + [i.get("title", "") for i in r.get("inlineProducts", [])]
return []
def named_order(engine, r):
if engine == "CHATGPT" and r.get("entities"):
names = [e.get("name", "") for e in r["entities"]]
return [b for n in names for b in TRACKED if b.lower() in n.lower()]
text = (r.get("text") or "").lower()
hits = [(m.start(), b) for b in TRACKED for m in [re.search(re.escape(b.lower()), text)] if m]
return [b for _, b in sorted(hits)]
def row(task, r):
_, run_date, _, engine, country, prompt_id = task["idempotencyKey"].split(":")
order = list(dict.fromkeys(named_order(engine, r)))
cited = [s.get("position") for s in r.get("sources", []) if on_domain(s.get("url"))]
products = " ".join(product_names(engine, r)).lower()
return (task["id"], run_date, engine, country, prompt_id,
int(BRAND in order), min(cited) if cited else None,
int(BRAND in order or BRAND.lower() in products),
order.index(BRAND) + 1 if BRAND in order else None,
json.dumps([urlparse(s.get("url") or "").hostname for s in r.get("sources", [])]),
json.dumps(r.get("searchQueries", [])))
app = Flask(__name__)
@app.post("/hooks/answers")
def answers():
raw = request.get_data()
if not verify(raw, request.headers.get("Webhook-Signature")):
abort(400)
delivery = json.loads(raw)
task = delivery.get("task", {})
if delivery.get("test") or task.get("status") != "COMPLETED" or not (task.get("idempotencyKey") or "").startswith("sov:"):
return "", 204
db.execute("insert or ignore into runs values (?,?,?,?,?,?,?,?,?,?,?)", row(task, delivery["response"]["result"]))
db.commit()
return "", 204
Notes on the code:
insert or ignoreon thetask_idprimary key handles duplicate and out-of-order deliveries.FAILEDtasks are not charged; log them and resubmit with a new run index if you need the sample.- Substring brand matching is a starting point. Add aliases and exclusions before reporting; measuring brand mentions covers that.
- Store the full result as well if you may want new metrics later; re-collecting costs money.
- The receiver must answer with a 2xx within 15 seconds; move slow work off the request path. See webhook reliability design.
3. Compute the metrics
With rows stored, each metric is a grouped query. Mention rate per engine and market for one day:
select engine, country, avg(mentioned) as mention_rate, count(*) as runs,
avg(cited_position is not null) as cited_rate, avg(answer_rank) as mean_rank
from runs where run_date = '2026-09-17'
group by engine, country;
For fan-out coverage, collect the distinct queries from the fanout column per market (empty on ChatGPT, Gemini, Perplexity and Copilot rows today), submit them as GOOGLE tasks with {"query": q, "country": country}, and in that receiver record the best organicResults[].position whose link passes on_domain.
Reporting
- Trends over snapshots: mention rate by week, citation share by engine, fan-out coverage by prompt cluster, each with n and a margin.
- Per engine first. Aggregate across engines only with the counts visible.
- Fixed definitions: the same prompts, markets and competitor list across periods; changes start a new series.
The report template shows a layout, and the AI visibility tracking use case lists the fields by engine. Start with the ChatGPT engine page.
Questions
What is AI share of voice?
It is the share of AI-generated answers, citations and recommendations in a defined prompt set that belong to your brand, measured per engine and market over repeated runs. It is a set of rates, not a single score.
Which engines return named entities?
Only ChatGPT returns an entities array, the brands and products an answer names as type and name pairs. On Gemini and the other engines you detect brands in the answer text.
How do I measure fan-out coverage?
Collect the searches the assistant ran from the engines that return them, run each query through Google Search, and compute the share of queries where your domain appears in organicResults. It shows whether you rank for the searches the assistant uses to find sources. ChatGPT's searchQueries field comes back empty on the answers served today and Gemini has none, so for those engines the metric waits for a query list.
How many runs do I need before trusting a share-of-voice number?
Size it from the margin of error you need. The standard error of a rate is sqrt(p(1-p)/n); at p = 0.5, about 97 runs give a 95% margin of roughly plus or minus 10 points.
Where is the answer in a webhook delivery?
The webhook body is the finished task JSON. For a COMPLETED task, the engine result is at response.result, and task.idempotencyKey carries the key you set, which you can use to look up the prompt and market.