Track how AI assistants mention a business. Each prompt belongs to one of its markets, so it is asked in that market's language and from inside it. You create prompts for the providers to answer, then read back each prompt run, the aggregated prompt stats and the periodic visibility reports.
Providers: chatgpt, claude, perplexity, gemini, google_ai_overview, google_ai_mode, meta_ai, copilot, grok, tiktok.
WARNING
Each provider answer costs credits, and scheduled runs reserve theirs before they start, so a prompt can't overdraw your balance.
The prompt object
{
"id": "prm_4k2Lx",
"object": "prompt",
"business_id": "biz_7hQm2",
"market_id": "mkt_3xUs",
"text": "best invisalign dentist near me",
"intent": "commercial",
"active": true,
"providers": ["chatgpt", "google_ai_mode", "perplexity"],
"monitor_interval": "dynamic",
"tags": ["invisalign"],
"created_at": 1761350400
}
List prompts
GET /v1/prompts
| Parameter | Type | Description |
|---|---|---|
business (required) |
string | biz_ ID |
market |
string | Only this mkt_ market |
tag |
string | Only prompts with this tag |
active |
true | false |
Only running or only paused prompts |
limit, starting_after |
Pagination |
Create a prompt
POST /v1/prompts
| Parameter | Type | Description |
|---|---|---|
business (required) |
string | biz_ ID |
market (required) |
string | mkt_ ID of one of the business's markets. Write text in one of its languages. |
text (required) |
string | Question sent to each provider, worded the way locals ask. Max 1000 chars. |
intent (required) |
"informational" | "transactional" | "commercial" |
What the asker wants |
providers |
string[] | Defaults to every provider. Each provider answers separately, and each answer costs credits. |
monitor_interval |
"dynamic" | "weekly" | "monthly" |
Default "dynamic". See monitor intervals. |
tags |
string[] | Up to 10. Lowercase letters, digits, hyphens, max 64 chars each. |
active |
boolean | Default true. false saves it paused, so it doesn't run or spend credits. |
Returns 201 with the prompt. Its first runs are queued right away; list them with GET /v1/prompt_runs?prompt=prm_….
curl -X POST "https://spysay.com/v1/prompts" \
-H "Content-Type: application/json" \
-H "x-spysay-api-key: <API_KEY>" \
-d '{"business":"biz_7hQm2","market":"mkt_3xUs","text":"best invisalign dentist near me","intent":"commercial","providers":["chatgpt","google_ai_mode"],"tags":["invisalign"]}'
import { createClient } from "@spysay/sdk";
const spysay = createClient({ baseUrl: "https://spysay.com", token: process.env.SPYSAY_API_KEY! });
const prompt = await spysay.prompts.create({
business: "biz_7hQm2",
market: "mkt_3xUs",
text: "best invisalign dentist near me",
intent: "commercial",
providers: ["chatgpt", "google_ai_mode"],
tags: ["invisalign"],
});
| Status | Codes |
|---|---|
400 |
parameter_invalid, parameter_unknown, parameter_missing |
402 |
insufficient_credits: the balance can't cover the first runs |
404 |
resource_missing: the business or market doesn't exist |
Retrieve, update and delete
| Endpoint | Description |
|---|---|
GET /v1/prompts/:id |
Retrieve a prompt |
PATCH /v1/prompts/:id |
Change any create field except business. Reactivating a paused prompt with active: true needs enough credits for its next runs (402 insufficient_credits). |
DELETE /v1/prompts/:id |
Returns { "id": "prm_…", "object": "prompt", "deleted": true } |
curl -X PATCH "https://spysay.com/v1/prompts/prm_4k2Lx" \
-H "Content-Type: application/json" \
-H "x-spysay-api-key: <API_KEY>" \
-d '{"active":false,"tags":["invisalign","paused"]}'
import { createClient } from "@spysay/sdk";
const spysay = createClient({ baseUrl: "https://spysay.com", token: process.env.SPYSAY_API_KEY! });
await spysay.prompts.update({ id: "prm_4k2Lx", active: false, tags: ["invisalign", "paused"] });
Check a prompt now
POST /v1/prompts/:id/check
Asks every engine the prompt again now, instead of waiting for its next scheduled check. The new answers replace today's runs. API engines answer within the call and cost their usual credits. Engines read in a browser answer within minutes: they're listed in waiting, so call again to collect them until waiting is empty.
A new check can be asked once an hour per prompt (429 rate_limited); calls while engines are still waiting only collect answers. A paused prompt can't be checked (400 parameter_invalid).
curl -X POST "https://spysay.com/v1/prompts/prm_4k2Lx/check" \
-H "x-spysay-api-key: <API_KEY>"
{ "object": "prompt_check", "prompt": "prm_4k2Lx", "requested_at": 1793005200, "waiting": ["gemini", "google_ai_mode"] }
Prompt stats
GET /v1/prompt_stats
Returns per-prompt performance and a business summary over a date range. Pass market for one market, as the overview's market tabs do.
| Parameter | Type | Description |
|---|---|---|
business (required) |
string | biz_ ID |
market |
string | Only prompts in this mkt_ market |
from |
YYYY-MM-DD |
Default: 30 days ago |
to |
YYYY-MM-DD |
Default: today |
provider |
string | Only this provider |
{
"object": "prompt_stats",
"business_id": "biz_7hQm2",
"range": { "from": "2026-09-25", "to": "2026-10-25" },
"data": [
{
"prompt_id": "prm_4k2Lx",
"text": "best invisalign dentist near me",
"last_run_at": 1761350400,
"responses": 120,
"mentions": 85,
"visibility_percent": 71,
"citation_percent": 65,
"avg_position": 2.3,
"avg_sentiment": 42,
"difficulty": 87,
"top_competitor_share_percent": 22,
"top_competitors": [{ "name": "Acme", "url": "https://acme.com", "mentions": 15, "strength": 93 }]
}
],
"summary": {
"responses": 120,
"mentions": 85,
"citations": 55,
"visibility_percent": 71,
"citation_percent": 65,
"share_of_voice_percent": 68,
"avg_position": 2.3,
"avg_sentiment": 42,
"top_competitor": { "name": "Acme", "url": "https://acme.com", "mentions": 15, "strength": 93 },
"competitors": [{ "name": "Acme", "share_percent": 40 }, { "name": "Bright Dental", "share_percent": 18 }]
}
}
competitors lists up to five rivals, ranked by the share of answers that name each one. An answer that names a rival several times counts once. Percentages run from 0 to 100, and so does difficulty, which stays null until enough runs exist. avg_sentiment runs from -100 to 100.
List prompt runs
GET /v1/prompt_runs
Returns each provider's answer to each prompt, along with how it was analyzed.
| Parameter | Type | Description |
|---|---|---|
business (required) |
string | biz_ ID |
market |
string | Only runs of prompts in this mkt_ market |
prompt |
string | Only this prm_ prompt |
provider |
string | Only this provider |
status |
string | Only this status |
from / to |
YYYY-MM-DD |
Date range |
limit, starting_after |
Pagination |
{
"id": "prr_9dQe",
"object": "prompt_run",
"prompt_id": "prm_4k2Lx",
"date": "2026-10-25",
"provider": "chatgpt",
"status": "analyzed",
"response": "Popular options include…",
"mention_position": 2,
"citation_count": 1,
"sentiment": 45,
"analysis": "Brand mentioned second, positively…",
"competitor_mentions": [{ "name": "Acme", "count": 3 }],
"citation_domains": ["lumendental.example", "yelp.com"],
"checked_from": { "country": "US", "city": "Austin", "method": "gps" },
"created_at": 1761350400
}
checked_from records where the answer was really fetched from. gps means the market's device location was sent, which Google uses. ip means only the exit IP placed the request; ChatGPT and most other providers go by IP alone. It is null until the run is answered.
status moves through queued → running → answered (response received, analysis pending) → analyzed, or ends at failed.
Full answers are kept for 90 days. After that, response, analysis and fan_out_queries come back empty, and the rest of the run (mention_position, competitor_mentions, citation_domains) stays, so history and stats still cover every day.
Visibility reports
GET /v1/visibility_reports
Lists a business's archived reports, newest first. Takes business, limit and starting_after.
{
"id": "rpt_5tVw",
"object": "visibility_report",
"business_id": "biz_7hQm2",
"type": "rolling",
"period_start": "2026-10-19",
"period_end": "2026-10-25",
"report": { "version": "v3", "quick_summary": "…", "action_items": [] },
"markdown": null,
"created_at": 1761350400
}
type is rolling for current reports. Older reports may be daily or weekly and carry markdown instead of a structured report.
GET /v1/visibility_reports/today
Returns the state of the current reporting period. Takes business.
{
"object": "visibility_report_status",
"business_id": "biz_7hQm2",
"period_end": "2026-10-25",
"report_id": null,
"runs_complete": false
}
report_id is set once the period's report exists. runs_complete is true once no runs are still queued, running or awaiting analysis.
Monitor intervals
| Interval | Behavior |
|---|---|
dynamic |
Adapts to how much answers change: stable prompts run less often, volatile ones daily or more. Default. |
weekly |
Once every 7 days |
monthly |
Once every 30 days |