cloro
Technical Guides

How to Track AI Search Visibility Programmatically

Nadia Mohamed
SEO Engineer, cloro
10 min read
AI SearchTrackingAPI
On this page

For fifteen years, “search visibility” meant one thing: where your URL sat in a list of ten blue links. That model is quietly breaking. A growing share of buyers now get their answer from a synthesized paragraph — generated by ChatGPT, Perplexity, Gemini, or Google’s AI Overviews — and never click through to a source at all.

The awkward part, if you own a website, is that this new surface is invisible to the tools you already run. Google Search Console won’t tell you whether ChatGPT recommends your product. Your rank tracker has no idea that Perplexity cited a competitor instead of you. There is no report to open, because the engines don’t ship one. If you want that data, you have to go and query it yourself.

This is a developer’s guide to tracking AI search visibility programmatically. You send a prompt to an AI answer engine over an API, parse the structured JSON it returns, and wrap the whole thing in a monitoring loop that runs on a schedule. The concepts are engine-agnostic. The worked examples use cloro’s AI visibility tracking API because it returns one JSON contract across every engine, but the same pattern works for any provider that hands back an answer plus its citations. If you’d rather stand up a working prototype in half an hour than read the full mechanics, the 30-minute AI visibility tracking setup is the express lane. This piece explains why each part looks the way it does.

Why AI search visibility is now an engineering metric

The first argument is scale. As of October 2025, OpenAI’s Sam Altman said more than 800 million people use ChatGPT every week. Google’s AI Overviews — the AI summary that now sits above the classic results — reached 2 billion monthly users in July 2025. Its conversational AI Mode passed 100 million monthly users off the back of just two markets, the US and India. Perplexity’s CEO reported the engine handled about 780 million queries in a single month, growing roughly 20% month over month. These are not fringe surfaces you can safely ignore for another year.

The second argument is behavioral. When an AI summary appears, people click less. A Pew Research analysis of the browsing data of 900 U.S. adults across 68,879 Google searches found that users who saw an AI summary clicked a traditional search result in just 8% of visits, versus 15% when no summary appeared — roughly half as often. The answer increasingly is the destination, so being named inside that answer is what matters.

The third argument is economic. Semrush’s analysis of AI search traffic estimates that the average AI search visitor is worth 4.4x the average traditional organic search visitor by conversion. It projects AI search visitors could overtake traditional search as early as 2028 for some topics. Fewer, higher-intent visits mean each mention carries more weight.

Worth a caveat on the forecasts, though. Back in 2024, Gartner predicted that traditional search engine volume would drop 25% by 2026 as query share moved to AI chatbots and virtual agents. It was contested from the start. An analysis by Datos of real browsing data found almost no indication that traditional search was on that path. Google held roughly 91% of traffic across the year they measured, and early AI-chat users trailed off after a few queries.

Which is rather the point. A headline forecast about everyone’s search behavior — in either direction — tells you nothing actionable about whether ChatGPT recommends your product this week. That’s not a number you can look up. It’s one you have to go and measure, which is the rest of this post.

Put those together and the discipline of getting cited inside AI answers — generative engine optimization, or GEO — becomes a channel you’re expected to report on. And you can’t optimize what you can’t measure. The good news for engineers: once you can hit this surface with an API, measuring it is not a black art. It’s a counting problem over JSON.

Two ways to measure it: the browser tab and the API

There are really only two ways to find out how an AI engine talks about your brand.

The manual way is to open the engine in a browser, type your prompt, and read the answer. It’s the right first move — do it once before you write a line of code, so you know what “good” and “bad” look like for your category. But it doesn’t scale, and it lies to you in two specific ways. First, personalization: if you’re logged in, the engine tailors the answer to your history, so what you see is not what a cold prospect sees. Second, sample size: model outputs are non-deterministic, so a single answer is an anecdote. You cannot chart a browser tab, and you cannot get paged when your share of voice drops overnight.

The programmatic way is to send the same prompts to the engines through an API, on a clean session, on a schedule, and store every result. This is the only approach that produces a trend line — and a trend line is the whole point. The trade-off is that most AI engines don’t expose a clean, citation-aware API of their own, so the practical path is a provider that runs the query for you and returns a parsed, normalized result. That’s the pattern the rest of this guide builds on. (Whether to build this yourself at all, versus buying a dashboard, is a real decision with real break-even math. We walk through it in AI search visibility: build vs buy.)

What “visibility” means when there are no rankings

There’s no position #1 in an AI answer, so we need different primitives. Three cover most of what teams actually report on, and — conveniently — all three reduce to counting operations over a consistent payload. The AI Visibility Leaderboard publishes these same primitives weekly for twelve software categories, so you can see what the output looks like at scale before writing any of it.

Mention

Does the answer name your brand anywhere in the generated text? This is the coarsest signal — a substring or entity check against the answer body — and it’s the closest AI-era analogue to a search impression: did you show up at all? A brand can be named in the prose without being linked, which is exactly why mention and citation are tracked as separate columns rather than collapsed into one.

Citation

Does the engine link to your domain in its sources list? Mentions live in prose; citations live in structured metadata. A citation is the stronger signal: it means the model treated your page as a reference, and it’s the one tied to actual referral traffic, because a citation is a clickable path back to your site. When mention rate is high but citation rate is low, the engines know who you are but aren’t sending visitors — an attribution gap, not an awareness gap, and it changes what you fix next.

Share of voice

Across a set of prompts that matter to your category, how often do you appear relative to named competitors? One prompt is anecdote; fifty prompts run daily is a trend line. Share of voice turns a yes/no into a percentage you can chart, benchmark, and alert on — and it’s the number executives ask for first, so capture the competitor columns from your very first run. For the formal definitions and the denominator subtleties (AI answers can name several brands at once), see the AI brand visibility measurement framework and our deeper piece on AI share of voice.

Querying an AI engine from code

The pattern is a single authenticated POST: you send a prompt to an engine and get back the answer text plus a structured list of sources. With cloro, the endpoint is POST https://api.cloro.dev/v1/monitor/{engine}, authenticated with a bearer token, where {engine} is one of chatgpt, perplexity, gemini, aimode, copilot, grok, or google.

import os
import requests

CLORO_API_KEY = os.environ["CLORO_API_KEY"]

def query_engine(prompt: str, engine: str = "chatgpt", country: str = "US") -> dict:
    """Send one prompt to one engine, return the parsed result object."""
    resp = requests.post(
        f"https://api.cloro.dev/v1/monitor/{engine}",
        headers={
            "Authorization": f"Bearer {CLORO_API_KEY}",
            "Content-Type": "application/json",
        },
        json={"prompt": prompt, "country": country, "include": {"markdown": True}},
        timeout=90,
    )
    resp.raise_for_status()
    # The envelope is {"success": true, "result": {...}}.
    return resp.json()["result"]

result = query_engine("What are the best AI SEO data APIs?")
print(result["text"][:280])
for source in result["sources"]:
    print(source["position"], source["label"], source["url"])

The response is a normalized JSON object. The three fields you care about are text (the answer paragraph), markdown (the same answer with formatting preserved, handy for entity checks), and sources. That last one is an array where each entry carries a position, a url, a label, and a description. Because that shape is identical across chatgpt, perplexity, gemini, aimode, and the rest, you write your parsing logic exactly once. The full field reference, including the engine-specific extras, lives in the cloro API docs.

The country parameter matters more than it looks: AI engines personalize by IP geography, so pinning the country is what makes two runs comparable. cloro requires it on every call, which is a good constraint: it’s the API equivalent of always testing from the same clean, logged-out session. You can’t accidentally build a trend line out of answers served to three different countries.

Worth knowing before you write any of this: every prompt in this guide runs on free credits. cloro gives new accounts 500 of them for exactly this kind of evaluation. Engines are priced individually: Perplexity is 3 credits a call, Gemini and AI Mode 4, ChatGPT 5 to 7 depending on the response you ask for. So the whole prototype below — three prompts across four engines — lands comfortably under 100. You can run it several times over before deciding whether any of this is worth paying for.

Turning a response into visibility signals

With a predictable payload, the three metrics become a few lines. Here we compute mention, citation, and citation position in a single pass over one result:

import re
from typing import Optional
from urllib.parse import urlparse

def cited_by(sources: list, domain: str) -> Optional[dict]:
    """First source whose host is `domain` (or a subdomain of it), else None."""
    target = domain.lower().removeprefix("www.")
    for source in sources:
        host = (urlparse(source.get("url") or "").hostname or "").lower()
        host = host.removeprefix("www.")
        if host == target or host.endswith("." + target):
            return source
    return None

def score_visibility(result: dict, brand: str, domain: str) -> dict:
    text = (result.get("markdown") or result.get("text") or "").lower()
    source = cited_by(result.get("sources", []), domain)

    return {
        "mentioned": re.search(rf"\b{re.escape(brand.lower())}\b", text) is not None,
        "cited": source is not None,
        "citation_position": source.get("position") if source else None,
    }

print(score_visibility(result, brand="cloro", domain="cloro.dev"))
# {'mentioned': True, 'cited': True, 'citation_position': 2}

That’s the whole trick. Everything downstream — dashboards, alerts, share-of-voice math — is aggregation over this one small, boring function. Keep it boring on purpose: the value of the program comes from running it consistently, not from clever scoring.

Two details that stop the counting from lying to you. Parse the host rather than substring-matching the URL. Otherwise notcloro.dev and cloro.dev.evil.com both score as citations of cloro.dev, while a legitimate subdomain of your own site — which you do want to count — is indistinguishable from either under a naive in check. Same story in the prose: match the brand on a word boundary, or a post about Clorox counts as a mention of cloro. These are the bugs that quietly inflate a dashboard nobody re-checks.

Adding competitors and computing share of voice

Share of voice needs a denominator: you plus the rivals you track. Extend the scorer to look for a set of competitor domains in the same response, then normalize across a prompt run.

def score_with_competitors(result: dict, brand_domain: str, rivals: list) -> dict:
    sources = result.get("sources", [])
    cited_brands = {d for d in [brand_domain, *rivals] if cited_by(sources, d)}
    return {
        "you_cited": brand_domain in cited_brands,
        "rivals_cited": [d for d in rivals if d in cited_brands],
        "field_size": len(cited_brands),  # how crowded this answer was
    }

Run that over a fixed prompt set and your share of voice for one engine is simply the count of answers that cited you divided by the count of answers that cited anyone in your tracked set. Freeze the competitor list for a quarter so the trend line stays comparable — moving the denominator every week makes the chart meaningless.

Building a monitoring loop

A single query is a spot check. Visibility is a moving target, because model outputs are non-deterministic and refresh as the engines re-crawl the web, so you want the same prompts run repeatedly and stored. The loop below fans a prompt set across several engines and records one flat row per result — the shape you’ll later load into a warehouse.

import csv
import datetime
import os

PROMPTS = [
    "best AI SEO data APIs",
    "how to track brand mentions in ChatGPT",
    "tools to monitor Perplexity citations",
]
ENGINES = ["chatgpt", "perplexity", "gemini", "aimode"]
RIVALS = ["tryprofound.com", "peec.ai", "otterly.ai"]

def run_sweep(brand: str, domain: str) -> list:
    rows = []
    stamp = datetime.date.today().isoformat()
    for engine in ENGINES:
        for prompt in PROMPTS:
            try:
                result = query_engine(prompt, engine=engine)
            except requests.HTTPError as exc:
                print(f"  ! {engine} / {prompt[:40]} -> {exc}")
                continue
            score = score_visibility(result, brand, domain)
            field = score_with_competitors(result, domain, RIVALS)
            rows.append({
                "date": stamp, "engine": engine, "prompt": prompt,
                **score, "field_size": field["field_size"],
            })
    return rows

rows = run_sweep(brand="cloro", domain="cloro.dev")

# Every call can fail, so `rows` can legitimately be empty -- guard before indexing.
if not rows:
    print("no successful queries this run")
else:
    new_file = not os.path.exists("visibility.csv")
    with open("visibility.csv", "a", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=rows[0].keys())
        if new_file:
            writer.writeheader()
        writer.writerows(rows)

by_engine = {}
for r in rows:
    bucket = by_engine.setdefault(r["engine"], {"cited_you": 0, "cited_anyone_tracked": 0})
    bucket["cited_you"] += int(r["cited"])
    bucket["cited_anyone_tracked"] += int(r["field_size"] > 0)

for engine, v in by_engine.items():
    if not v["cited_anyone_tracked"]:
        print(f"{engine}: no tracked brand cited yet")
    else:
        print(f"{engine}: {v['cited_you'] / v['cited_anyone_tracked']:.0%} share of voice")

Run this on a cron schedule or a scheduled GitHub Action once a day, append to the CSV (or, better, a database table), and within a couple of weeks you have a real time series. Two operational notes will save you a bad afternoon. Respect the provider’s rate limits by keeping the prompt set focused rather than exhaustive. And because answers vary between runs, trust trends across repeated samples over any single response. A brand that appears in 4 of 12 prompts today and 9 of 12 next month is telling you something; one lucky mention is not.

Scaling the loop without babysitting it

The synchronous loop above is perfect for a 50-prompt prototype. Past that, two things change.

First, a wide sweep — say 100 prompts across 6 engines daily — is 18,000 calls a month, and holding a connection open for each one stops being sensible. That’s what the async task API is for: instead of POSTing to a per-engine path, you submit a task and let cloro call you back.

requests.post(
    "https://api.cloro.dev/v1/async/task",
    headers={"Authorization": f"Bearer {CLORO_API_KEY}"},
    json={
        "taskType": "CHATGPT",  # AIMODE | GOOGLE | GEMINI | COPILOT | PERPLEXITY | GROK
        "payload": {"prompt": "best AI SEO data APIs", "country": "US"},
        "webhook": {"url": "https://example.com/hooks/cloro"},
    },
    timeout=30,
).json()
# {"success": true, "task": {"id": "…", "status": "QUEUED", …}, "credits": {…}}

Note what changed: the engine moves out of the URL and into an uppercase taskType, and the prompt moves under payload. So this is a different request shape from the synchronous call, not the same one with a flag flipped — budget for a small adapter rather than a find-and-replace. What you get back immediately is a task ID; the result arrives at your webhook when it lands, or you poll GET /v1/async/task/{taskId} for it. POST /v1/async/task/batch submits a whole sweep in one call. Either way an orchestrator (Airflow, Temporal, or plain cron firing a queue) drives the cadence without burning synchronous concurrency.

The payoff of the single response shape still holds where it counts: whatever route the answer arrives by, it carries the same text / markdown / sources payload, so score_visibility never changes.

Second, cost becomes a line item — and it argues for async as well. Synchronous calls carry a +2 credit surcharge; the async endpoints don’t. That sounds trivial until you multiply it. On the 18,000-call sweep above, the surcharge alone is 36,000 credits a month, paid purely for the privilege of holding connections open. That’s roughly a third of the bill. The arithmetic doesn’t care which engines you pick: it’s 2 credits times every call you make.

More generally, cloro bills a credit-based subscription rather than per request. Each plan carries a fixed monthly credit allowance, every engine draws down the same balance, and credits don’t roll over. So the question to model isn’t a per-call price — it’s whether a month of sweeps fits the allowance. Each response reports its own cost in an X-Credits-Charged header, which turns that from a guess into a measurement, the same way the rest of this post does. Check the plans against your sweep size before you widen the prompt set.

What to do with the data

Collecting the numbers is the easy part. AI search visibility only pays off when the data changes what you build next:

  • Alert on drops. If your share of voice for a priority prompt falls below a threshold, fire a Slack or email notification. A sudden disappearance often means a competitor published something the models started preferring.
  • Find citation gaps. Filter for prompts where you’re mentioned but not cited. The model knows you exist but isn’t linking you — usually a sign the authoritative page on that topic is a competitor’s, and a direct cue for what to write next.
  • Prioritize content by engine. If you’re strong in Perplexity but absent from Google’s AI Overviews, that’s a concrete, ranked backlog rather than a vague “do more AI SEO.” Engine disagreement is signal, not noise.
  • Close the loop. Feed the source URLs the engines do cite back to your content team. Those pages are your competitive set for AI answers, the same way top-ranking URLs are your competitive set for classic SEO. Re-run the sweep after each content change and diff the citations — that before/after diff is the measurement half of GEO.

The mindset shift is the real takeaway: AI search visibility is not a mystical black box. It’s a queryable surface. Once you can hit it with an API, parse the JSON, and store a time series, it becomes just another engineering metric — one you can graph, alert on, and improve deliberately instead of guessing about.

When to stop building and buy the pipeline

Building your own loop is the right call when you already run a warehouse and want AI-visibility data living next to the rest of your marketing analytics, on your own share-of-voice definitions. It stops being the right call the moment maintaining schedulers, dashboards, and competitor entity-resolution becomes someone’s second job. At that point you either graduate to a hosted dashboard — we compared the field in Profound alternatives. Or you keep the raw API and let cloro’s AI visibility tracking API be the production layer while you own the dashboard. Either way, the code in this guide is what proves the program is worth running before you spend a cent scaling it.

Nadia Mohamed

About the author

SEO Engineer, cloro

Nadia is an SEO engineer at cloro, where she leads SEO and generative-engine optimization (GEO) — the technical work and the content behind it. A software engineer by training, she removes the usual bottleneck between strategy and implementation — she ships the ideas she scopes, with no dev handoff.

Frequently asked questions

How is programmatic AI visibility tracking different from a rank tracker?+

A rank tracker records where your URL sits in a list of links. AI visibility tracking records whether a generated answer names or links your brand at all. There is no ranked list to scrape, so you send the prompt to the engine, read the answer text plus its sources array, and count mentions and citations yourself in code.

Which AI engines can I track through one API?+

cloro exposes ChatGPT, Perplexity, Gemini, AI Mode, Copilot, Grok, and Google Search (with AI Overview) behind one API, as a set of sibling paths that share a response contract. Every engine returns the same core response shape, so adding an engine to a synchronous monitoring loop is a URL change, not a new parser.

How often should the monitoring loop run?+

Daily is a sensible default for a focused prompt set; weekly is enough for most B2B brands. Because AI answers are non-deterministic and drift as models re-crawl the web, the value is in the trend across many repeated samples, not in any single run. Keep the prompt list small and meaningful so you stay within rate and credit limits.

Can I do this without a third-party API?+

You can script a headless browser against each engine, but then you own the brittle scraping, citation parsing, personalization control, and anti-bot handling for every engine separately. A provider that normalizes each engine into one JSON shape removes most of that maintenance, which is why the examples here use one.

Is one JSON response shape really the same across every engine?+

The core fields are: text, markdown, and sources, where each source carries position, url, label, and description. Those are consistent across engines, so you write your parsing and scoring logic once. Engine-specific extras ride alongside the core fields for the cases that need them.