playbooks14 min read

How to build an AI visibility monitor with the AI citation API (2026)

Build an AI visibility monitor in 2026: schedule daily POST /api/v1/search calls, diff the cited-source set, store snapshots, and alert on new or lost sources.

A
Alexis MarescaCofounder, Getspotted · GEO & AI visibility expert

What you build and why

To build an AI visibility monitor, schedule a daily POST /api/v1/search call per query and country, store the returned cited-source set as a dated snapshot, diff each new snapshot against the previous one, and alert when a source appears or disappears. This guide ships copy-paste code for all four steps. An AI citation API such as Getspotted returns the sources ChatGPT, Claude, Perplexity, Gemini, and Google cite for a query, so a monitor watches the right object: the cited sources, not your keyword rank. As of 2026, this build takes roughly 150 lines and runs on a single cron job.

This is the developer build-along. For the no-code path (Zapier, Make, n8n) see the docs examples and integrations; this article uses those as primitives and writes the code instead.

The data model: what an AI visibility monitor tracks

An AI visibility monitor tracks the cited-source set: the ranked list of URLs that AI engines cite when answering a query in a specific country. A Getspotted POST /api/v1/search call returns each cited source with a url, domain, score, recommendationLevel (PREMIUM, HIGH, MEDIUM, LOW), the engines that cited it, and a crossEngine boolean. The monitor persists that set per day, then compares sets across days.

The unit of change you care about is a source entering or leaving the set, not a rank moving by one position. Three fields drive every alert:

FieldTypeWhat it tells the monitor
`domain`stringThe stable identity key for diffing across snapshots
`score`numberRelative citation strength (rank the set by this)
`crossEngine`booleanWhether multiple engines cite the source (a hotspot)
`recommendationLevel`enumPREMIUM/HIGH/MEDIUM/LOW bucket for thresholds
`engines`string[]Which engines (ChatGPT, Claude, Perplexity, Gemini, Google) cited it

A response also returns a hotspots array (sources several engines cite at once) and a crossMatchCount integer. Read the full field list in the API reference and the concept in the AI citation API glossary entry.

Step 1: Fetch the cited sources for one query

A single POST /api/v1/search call costs 10 credits and returns the cited-source set for one query in one country. Send { query, country } with a Bearer key, read data.sources, and you have one engine's worth of truth. The function below wraps the call, throws on a non-200, and returns the typed source array. Keep the key server-side; never ship gsp_live_ to a browser.

// lib/getspotted.ts
const API = "https://getspotted.ai/api/v1";

export interface CitedSource {
url: string;
domain: string;
title: string;
score: number;
recommendationLevel: "PREMIUM" | "HIGH" | "MEDIUM" | "LOW";
engines: string[];
crossEngine: boolean;
}

export async function fetchSources(
query: string,
country = "US"
): Promise<CitedSource[]> {
const res = await fetch(`${API}/search`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.GETSPOTTED_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ query, country }),
});
if (!res.ok) {
throw new Error(`search failed: ${res.status}`);
}
const { data } = await res.json();
return data.sources as CitedSource[];
}

The call runs every engine by default, so no per-engine flag is needed. To monitor a query across several markets, call fetchSources(query, "US") and fetchSources(query, "DE") separately, because the cited-source set changes by country.

Step 2: Store each result as a dated snapshot

A snapshot is the cited-source set for one query and country on one date, stored so a later run can diff against it. Persist three columns at minimum: a key (query plus country), a captured_at date, and the sources JSON. Any store works; the example uses a single table and writes one row per query, per country, per day. Storing the full source array (not just domains) lets the monitor later report score and engine changes, not only set membership.

-- one row per query, per country, per day
create table source_snapshots (
id bigint generated always as identity primary key,
monitor_key text not null, -- e.g. "best CRM for B2B SaaS::US"
captured_at date not null default current_date,
sources jsonb not null, -- the CitedSource[] payload
unique (monitor_key, captured_at)
);

// lib/store.ts
import { sql } from "./db"; // your Postgres client
import type { CitedSource } from "./getspotted";

export async function saveSnapshot(
key: string,
sources: CitedSource[]
): Promise<void> {
await sql`
insert into source_snapshots (monitor_key, sources)
values (${key}, ${JSON.stringify(sources)}::jsonb)
on conflict (monitor_key, captured_at) do update
set sources = excluded.sources
`;
}

export async function previousSnapshot(
key: string
): Promise<CitedSource[] | null> {
const rows = await sql<{ sources: CitedSource[] }[]>`
select sources from source_snapshots
where monitor_key = ${key} and captured_at < current_date
order by captured_at desc limit 1
`;
return rows[0]?.sources ?? null;
}

The unique (monitor_key, captured_at) constraint makes a same-day re-run idempotent: a second run overwrites instead of duplicating. The previousSnapshot query deliberately filters captured_at < current_date so today's write never diffs against itself.

Step 3: Diff two snapshots into gained, lost, and moved

A diff turns two cited-source sets into three lists: sources gained (in today, not yesterday), lost (in yesterday, not today), and moved (in both, with a score change above a threshold). Diff on domain, because a domain is stable while a URL path can vary between answers. The function below returns a structured diff that an alerter can render directly.

// lib/diff.ts
import type { CitedSource } from "./getspotted";

export interface SourceDiff {
gained: CitedSource[];
lost: CitedSource[];
moved: { domain: string; from: number; to: number }[];
}

export function diffSources(
before: CitedSource[],
after: CitedSource[],
moveThreshold = 15
): SourceDiff {
const beforeBy = new Map(before.map((s) => [s.domain, s]));
const afterBy = new Map(after.map((s) => [s.domain, s]));

const gained = after.filter((s) => !beforeBy.has(s.domain));
const lost = before.filter((s) => !afterBy.has(s.domain));

const moved: SourceDiff["moved"] = [];
for (const [domain, a] of afterBy) {
const b = beforeBy.get(domain);
if (b && Math.abs(a.score - b.score) >= moveThreshold) {
moved.push({ domain, from: b.score, to: a.score });
}
}
return { gained, lost, moved };
}

The moveThreshold (default 15 score points) suppresses noise: AI answers vary run to run, so a small score wobble is not a real change. Raise the threshold to 25 if your alert channel is noisy, or filter moved to recommendationLevel === "PREMIUM" sources only.

Step 4: Alert on new or lost hotspot sources

An alert fires when the diff contains a gained or lost source, with priority given to hotspot sources (where crossEngine === true, meaning multiple engines cite the domain at once). A lost hotspot is the highest-signal event a monitor can raise: a source several AI engines trusted stopped being cited. The function below builds a Slack-ready message and only posts when something changed.

// lib/alert.ts
import type { SourceDiff } from "./diff";

export function formatAlert(key: string, diff: SourceDiff): string | null {
const gainedHot = diff.gained.filter((s) => s.crossEngine);
const lostHot = diff.lost.filter((s) => s.crossEngine);
if (!diff.gained.length && !diff.lost.length && !diff.moved.length) {
return null; // nothing changed: stay silent
}
const lines = [`*AI visibility change* for ${key}`];
if (gainedHot.length)
lines.push(`New hotspot sources: ${gainedHot.map((s) => s.domain).join(", ")}`);
if (lostHot.length)
lines.push(`Lost hotspot sources: ${lostHot.map((s) => s.domain).join(", ")}`);
if (diff.gained.length)
lines.push(`Gained (${diff.gained.length}): ${diff.gained.map((s) => s.domain).join(", ")}`);
if (diff.lost.length)
lines.push(`Lost (${diff.lost.length}): ${diff.lost.map((s) => s.domain).join(", ")}`);
return lines.join("\n");
}

export async function sendSlack(text: string): Promise<void> {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
}

Returning null on no-change keeps the channel quiet, which is what makes a monitor usable past week one. Route gained and lost hotspots to a separate high-priority channel if your team only wants the strongest signals.

Step 5: Wire it to a daily cron job

A cron job runs the monitor once per day: it loops over your tracked queries, fetches the current sources, loads the previous snapshot, diffs them, alerts on change, and saves today's snapshot. The orchestration below is the whole monitor. On a Vercel cron or any scheduler, point a GET /api/cron/monitor route at it and set the schedule to once daily.

// app/api/cron/monitor/route.ts (Vercel cron: "0 8 * * *")
import { fetchSources } from "@/lib/getspotted";
import { saveSnapshot, previousSnapshot } from "@/lib/store";
import { diffSources } from "@/lib/diff";
import { formatAlert, sendSlack } from "@/lib/alert";

const MONITORS = [
{ query: "best CRM for B2B SaaS", country: "US" },
{ query: "best project management tool", country: "DE" },
];

export async function GET() {
for (const m of MONITORS) {
const key = `${m.query}::${m.country}`;
const current = await fetchSources(m.query, m.country);
const previous = await previousSnapshot(key);
if (previous) {
const diff = diffSources(previous, current);
const msg = formatAlert(key, diff);
if (msg) await sendSlack(msg);
}
await saveSnapshot(key, current);
}
return Response.json({ ok: true, monitors: MONITORS.length });
}

The order matters: diff against the previous snapshot before saving today's, or today's write would overwrite the baseline you need to compare against. Each monitor entry spends 10 credits per run, so 2 queries daily spend 20 credits per day. Check remaining balance any time with GET /api/v1/usage.

Scaling cost, country coverage, and rate limits

Monitor cost scales linearly with queries times countries times runs: 50 queries across 3 countries, daily, equals 150 searches per day at 10 credits each, or 1,500 credits per day. Two levers cut spend without losing signal: drop the cadence on stable queries to every 3 days, and only run extra countries on queries where the market actually changes the cited-source set.

Three rules keep a scaled monitor honest, as of 2026:

  • Diff per country, never merge markets. The cited-source set for "best CRM" in the US differs from Germany, so a US snapshot must only diff against a US snapshot.
  • Cache same-day reads. The unique (monitor_key, captured_at) constraint already prevents duplicate daily writes; do not call /api/v1/search twice for the same key on the same day.
  • Add an editor contact step only when you act. When a hotspot you want to influence appears, call POST /api/v1/contacts with the domain (2 credits) to get the editor contact, rather than enriching every source on every run.

For the underlying build-versus-buy math (why the data layer, not the chart, is the hard part), see GEO API vs dashboard. To measure competitor share rather than raw set change, see how to measure AI share of voice, which is a different metric layered on the same snapshots.

From monitor to AI agent: the MCP path

The same data that powers the cron monitor is available to AI agents over MCP, so a coding agent (Claude Code, Cursor) can fetch cited sources during a task instead of calling REST. An MCP server exposes the search and contacts tools to the agent directly, which suits an autonomous "check my AI visibility and draft outreach" workflow rather than a fixed daily report. The cron build and the MCP path read the same cited-source data; the difference is who triggers the call: a scheduler, or an agent mid-task.

Wire an agent through the MCP server or the agents guide. Both call the API documented in the docs, so a team can run the cron monitor and an agent against one API key.

Ship it: get an API key

Build this monitor against your own queries: clone the five functions above, set GETSPOTTED_API_KEY and SLACK_WEBHOOK_URL, and point a daily cron at the route. The docs cover authentication and the first call in two minutes, the API reference lists every field used here, and the examples show the no-code equivalents. Start with the docs and your live key.

FAQ

How do I build an AI visibility monitor with an API?

Build an AI visibility monitor in five steps: schedule a daily POST /api/v1/search call per query and country, store each cited-source set as a dated snapshot, diff today's set against the previous one on domain, alert when a source is gained or lost, and run the loop from a single cron job. The full build is roughly 150 lines of code.

Which API endpoint returns the sources AI engines cite?

POST /api/v1/search returns the cited-source set for a query and country. Each source includes url, domain, score, recommendationLevel, the engines that cited it, and a crossEngine flag. The same response includes a hotspots array for sources several engines cite at once. The call costs 10 credits and queries every engine by default.

How do I track AI citations programmatically over time?

Track AI citations programmatically by persisting each POST /api/v1/search result as a dated snapshot, then diffing snapshots across days on the domain field. Sources present today but absent yesterday are gained; sources absent today but present yesterday are lost. Store the full source array, not just domains, to also report score and engine changes.

How often should an AI visibility monitor run?

Run an AI visibility monitor once per day for active queries and every 3 days for stable ones. AI answers vary run to run, so a same-day re-run wastes credits and a sub-daily cadence adds noise without signal. A daily cron with a score moveThreshold of 15 points filters wobble while still catching real source gains and losses.

How much does it cost to monitor AI citations with Getspotted?

Each POST /api/v1/search call costs 10 credits, so cost scales as queries times countries times runs. Monitoring 50 queries across 3 countries daily spends 1,500 credits per day (150 searches). Cut cost by lowering cadence on stable queries and only adding countries where the cited-source set actually differs. Editor-contact lookups cost an extra 2 credits each via POST /api/v1/contacts.

Can an AI agent run this monitor instead of a cron job?

Yes. The cited-source data is exposed over MCP, so an AI agent (Claude Code, Cursor) can fetch sources during a task instead of a scheduled REST call. A cron job suits a fixed daily report; an MCP-driven agent suits an on-demand "check visibility and draft outreach" workflow. Both read the same data through the MCP server and one API key.

How is this different from tracking Google rankings?

A rankings tracker watches your page position in Google's blue links. An AI visibility monitor watches the cited-source set: the URLs AI engines actually quote when answering a query, which often differ from the top Google results. The monitor diffs source membership and hotspots, not keyword positions. See Google rankings vs AI citations for the distinction.

GEOAI visibilityAI citation APItutorialdevelopers
A

Written by

Alexis Maresca

Cofounder, Getspotted · GEO & AI visibility expert

Alexis Maresca is a cofounder of Getspotted and a specialist in Generative Engine Optimization (GEO). He helps brands and agencies understand which sources AI engines like ChatGPT, Perplexity, Claude and Google AI Overviews cite, and how to get featured in AI-generated answers.

See what AI recommends to your buyers

Scan 6 AI engines in one click. Find the sources they cite. Get your brand featured.

Try Getspotted free