Integrations and API
REST API
Pull your AI Peekaboo data programmatically using an API key, with read or read+write scopes, filtering, and bulk writes.
7 minute read · Last reviewed: August 14, 2026
You want to pull your AI Peekaboo data programmatically, whether that's for a custom dashboard, an automation, or feeding another tool.
Base URL and authentication
The API base URL is:
https://www.aipeekaboo.com/api/v1Authenticate every request with an X-API-Key header (a lowercase x-api-key or an Authorization: Bearer header work too). Your key looks like:
pk_{projectId}_{secret}Generate a key from Settings → Integrations. You can revoke a key at any time from the same page.
Read vs. read+write scopes
When you generate a key, you choose its scope:
- Read keys can pull data: brands, visibility, competitors, sources, prompts, categories, recommended prompts, and eligible local tracking data.
- Read+write keys can also create, update, and delete brands, prompts, competitors, and categories, and configure or pause eligible local tracking.
The read endpoints you'll use most
These GET endpoints cover the same core data you see in the dashboard. Any valid key can call them.
| Endpoint | Method | What it returns |
|---|---|---|
/brands | GET | Every brand in your project, with basic detail |
/brands/:brandId | GET | One brand's metadata: name, URL, industry, prompt and competitor counts, analysis frequency |
/brands/:brandId/snapshot | GET | Visibility score, top prompts, top sources, competitor rankings, and traffic in one pre-computed call |
/brands/:brandId/visibility | GET | Visibility score, trend, and market share for a 7d, 30d, or 90d window |
/brands/:brandId/visibility/timeseries | GET | Daily visibility score for your brand and its competitors over a 7d, 30d, or 90d window: the chart data behind the headline score |
/brands/:brandId/visibility/by-model | GET | Visibility score, run count, and average position broken down by AI model |
/brands/:brandId/competitors | GET | Your live tracked competitor list: scores on the same formula as your brand, plus traffic, rank, and windowed visibility, sentiment and average position |
/brands/:brandId/sources | GET | Domains cited by AI models, with model attribution and mention counts |
/brands/:brandId/prompts | GET | Your tracked prompts with score, trend, best and worst score, and search intent |
/brands/:brandId/prompts/:promptId | GET | One prompt's per-run history, including cited article URLs and every entity mentioned |
/brands/:brandId/categories | GET | Your prompt categories with aggregate performance per category |
/brands/:brandId/recommendations | GET | Gap-driven prompt suggestions tied to real measured gaps, not generic ideas |
A worked example: the snapshot call
One authenticated GET gives you the whole picture for a brand:
curl -H "X-API-Key: $API_KEY" \
https://www.aipeekaboo.com/api/v1/brands/$BRAND_ID/snapshotThe response wraps everything in the standard envelope, with the payload under data:
{
"success": true,
"data": {
"brand": { "id": "a1b2c3d4-...", "name": "Acme Corp" },
"snapshotDate": "2026-02-24T02:00:00.000Z",
"visibility": {
"score": 42,
"rank": 3,
"maxScore": 100,
"totalCitations": 87,
"totalChatsAnalyzed": 40
},
"prompts": [
{
"promptText": "Best project management tools for teams",
"category": "Discovery",
"mentions": 8,
"averageScore": 78,
"aiModels": ["gpt-4o-mini", "gemini-2.5-flash", "sonar"]
}
],
"sources": [
{ "domain": "forbes.com", "mentions": 12, "aiModels": ["gpt-4o-mini"] }
],
"competitors": [
{
"id": "c1d2e3f4-...",
"name": "Competitor A",
"url": "https://competitor-a.com",
"score": 58,
"change": "+12%",
"monthlyVisits": 4500000,
"globalRank": 12000,
"rank": 1
}
],
"aiSuggestions": ["Focus on discovery prompts where competitors outrank you"],
"traffic": {
"monthlyVisits": 250000,
"globalRank": 45000,
"countryRank": 8500,
"bounceRate": 0.42,
"pagesPerVisit": 3.2,
"avgTimeOnSite": 185
},
"window": "latest_run"
},
"metadata": {
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-02-25T14:30:00.000Z",
"queryTimeMs": 10
}
}Every response uses that same envelope: success, then data, then metadata carrying a requestId worth quoting in any support ticket. Paginated endpoints add a pagination object with offset, limit, total, and hasMore. If analysis hasn't run for the brand yet, snapshot comes back null rather than erroring.
Charting your daily trend
/visibility/timeseries returns one score per UTC day for your brand plus every tracked competitor, which is what you want if you're building your own chart rather than reading the single headline number from /visibility:
curl -H "X-API-Key: $API_KEY" \
"https://www.aipeekaboo.com/api/v1/brands/$BRAND_ID/visibility/timeseries?time_range=30d"Each point is that day's run-weighted mean over a whole UTC day. Days can carry unequal run counts, so averaging the daily points yourself will NOT equal the /visibility headline: the headline uses a rolling window and a different aggregation (a pooled mean across every run in the window, not a mean of daily means). The series array has days + 1 entries, inclusive of both the start and end of the requested window (a time_range=30d request returns 31 points, one per day plus today).
Two null-handling rules matter here, and both trace back to the same principle as the dashboard: never draw an unmeasured day as a 0.
- `score: null` means unmeasured, not zero. A day with no analysis runs at all renders
nullon every entity's series for that day. A real0means a run happened and the entity genuinely wasn't mentioned; those are different facts, and averaging them together would understate everyone's score. - Every tracked competitor appears, even ones with `measured: false`. A competitor that was never mentioned anywhere in the window still shows up in the
competitorsarray (so you don't have to guess whether it's tracked), withmeasured: false. That flag is informational only, not a rule about the scores themselves: an unmeasured competitor still gets a real0on any day your brand ran and was simply not mentioned, andnullonly on a day your brand itself has no runs - the identical rule above, applied per competitor. A soft-deleted competitor, by contrast, is dropped from the array entirely. A competitor added to tracking part-way through the window scores0for the days before it was tracked, because rollup rows exist only for runs where it was mentioned. - An all-null response plus a `note` object means there's nothing in the rollups to draw, never that visibility is actually zero.
note.codeis'NO_ROLLUP_DATA'- either nothing ran in the window, or your brand hasn't been backfilled for it, and the API can't tell those apart - or'PRE_ROLLUP_EPOCH'(the window predates rollup coverage entirely);note.messagespells out the same thing in prose.
/visibility/by-model follows the same null rule per AI model: a model with zero runs in the window returns visibilityScore: null, and averagePosition is null whenever there's no ranked mention in the window (which includes, but isn't limited to, zero runs) - never 0 for either.
Passing include_competitors=false skips the competitor lookup, but the competitors key stays in the response as an empty array - it is never dropped, so you don't have to branch on its absence.
Both endpoints have an MCP twin (get_visibility_timeseries, get_model_breakdown), but the tools are not the same code path and their payloads are shaped differently, so don't expect a field-for-field match. The REST responses expose the window as range; the MCP tools keep it named window. get_model_breakdown's five model rows do match REST's exactly. get_visibility_timeseries differs more: it returns only the days that have data (REST returns every day of the window), uses 0 where REST uses null for an unmeasured day, and lists only competitors that actually have data rather than every tracked one. Neither is capped. Where both report a value for the same day, the number is the same.
Query parameters for filtering and paging
| Parameter | Default | Accepted values | Works on |
|---|---|---|---|
time_range | 7d | 7d, 30d, 90d | /visibility, /visibility/timeseries, /visibility/by-model, /prompts, /prompts/:promptId, /categories, /competitors (on /competitors, only its visibility, sentimentPositive, and avgPosition fields. See the gotcha below.) |
include_competitors | true | true, false | /visibility/timeseries |
offset | 0 | 0 or greater | /prompts |
limit | 50 | 1 to 200 | /prompts |
category | none | Any of your category names | /prompts |
search_intent | none | INFORMATIONAL, COMMERCIAL, TRANSACTIONAL, NAVIGATIONAL, LOCAL, INVESTIGATIONAL, SENTIMENT, BRANDED | /prompts, /categories |
Ninety days is the longest window the API will serve, and no all-time option exists, so anything longer has to be pulled on a schedule and stored on your side.
curl -H "X-API-Key: $API_KEY" \
"https://www.aipeekaboo.com/api/v1/brands/$BRAND_ID/prompts?time_range=30d&search_intent=COMMERCIAL&limit=200&offset=0"The write endpoints
A read+write key unlocks create, update, and delete across brands, prompts, competitors, and categories. The bulk routes are the reason to bother: the dashboard adds items one at a time, and these load a whole account in one call.
| Endpoint | Method | What it does |
|---|---|---|
/brands | POST | Create a brand (name required, plus optional url, industry, productDescription) |
/brands/:brandId | PUT | Update a brand's name, URL, industry, or description |
/brands/:brandId | DELETE | Soft-delete a brand |
/brands/:brandId/prompts | POST | Add one prompt |
/brands/:brandId/prompts/bulk | POST | Add up to 100 prompts in one request |
/brands/:brandId/prompts/:promptId | PUT | Edit a prompt's text or category |
/brands/:brandId/prompts/:promptId | DELETE | Soft-delete one prompt |
/brands/:brandId/prompts/bulk-delete | POST | Soft-delete up to 100 prompts by ID |
/brands/:brandId/competitors | POST | Add one competitor |
/brands/:brandId/competitors/bulk | POST | Add up to 50 competitors in one request |
/brands/:brandId/competitors/:competitorId | PUT / DELETE | Update or remove a competitor |
/brands/:brandId/categories | POST | Create a prompt category |
/brands/:brandId/categories/:categoryId | PUT / DELETE | Update or remove a category |
Field length limits are enforced server side: brand and competitor names up to 200 characters, URLs up to 500, prompt text up to 1,000, category names up to 100, and product descriptions up to 2,000. Anything longer comes back as INVALID_PARAMS rather than being silently truncated.
Error codes and what they mean
Errors come back with success: false and an error object holding a code, a human-readable message, and sometimes details.
| Code | HTTP | What went wrong |
|---|---|---|
UNAUTHORIZED | 401 | No X-API-Key header, or the key is invalid or revoked |
FORBIDDEN | 403 | A read-only key hit a write endpoint, or writes are temporarily disabled |
SUBSCRIPTION_INACTIVE | 403 | No active subscription on the project, or it has lapsed |
LIMIT_EXCEEDED | 403 | You hit a plan limit, typically max prompts, competitors, or brands |
INVALID_PARAMS | 400 | Bad UUID, out-of-range pagination, or a body that failed validation |
NOT_FOUND | 404 | The brand or prompt doesn't exist, or isn't in your project |
CONFLICT | 409 | The change collides with existing state, such as a duplicate prompt or competitor name |
RATE_LIMITED | 429 | Per-minute or daily limit reached |
INTERNAL_ERROR | 500 | Something broke on our side. Send us the requestId from metadata |
A brand that belongs to another project returns NOT_FOUND rather than FORBIDDEN, so a 404 on an ID you believe is valid usually means the key belongs to a different project.
Rate limits by plan
Limits are counted per API key, on a per-minute window plus a daily budget that resets at midnight UTC.
| Plan | Per minute | Per day |
|---|---|---|
| Starter and Peek plans | 20 | 300 |
| Grow plans | 20 | 1,000 |
| Custom and Enterprise plans | 40 | 2,000 |
Every response, not just a 429, carries the full set of counters:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Your per-minute ceiling |
X-RateLimit-Remaining | Requests left in the current minute |
X-RateLimit-Reset | Unix epoch in seconds when the minute window resets |
X-RateLimit-Daily-Limit | Your daily ceiling |
X-RateLimit-Daily-Remaining | Requests left today |
Read X-RateLimit-Remaining before firing the next call and sleep until X-RateLimit-Reset when it reaches zero, rather than retrying into a wall of 429s. Starter and Peek keys hit the ceiling almost immediately, so production automations belong on Grow or higher.
Gotchas worth knowing before you build
- Prompt history uses `aiModel`, not `model`. In each
/prompts/:idhistory entry, the model name lives in theaiModelfield. Readingmodelreturns nothing and collapses every row to "unknown". - Prompt history caps at 100 entries. That endpoint returns the 100 most recent runs with no pagination. Since each run produces one entry per model, a daily brand only reaches back about 20 days. To keep more history, pull on a schedule and store it yourself. Check
summary.truncated(withsummary.historyLimit) rather than assuming a shorthistory[]means you got everything in range:truncated: truemeans more runs existed than fit. - Only `7d`, `30d`, and `90d` windows work. Any other
time_rangevalue silently falls back to 7 days rather than erroring, so a typo quietly returns the wrong window. - `/snapshot` and `/sources` don't accept `time_range` at all. Both always return the latest analysis run regardless of any
time_rangeyou send. The response'swindowfield is always"latest_run", and anignoredParams: ["time_range"]array appears if you sent one, so you can tell it had no effect instead of assuming it filtered anything. - `/competitors` is a partial exception.
score,change, andrankstill come only from the latest analysis run (window: "latest_run", unaffected bytime_range), buttime_rangenow sizes the window for three other per-competitor fields:visibility,sentimentPositive, andavgPosition, reported back asmetricsWindow:{ from, to, days, timeRange }, wherefromandtoareYYYY-MM-DDUTC days and both ends are included, sodays: 7covers 8 dates. There is noignoredParamson this endpoint any more, sincetime_rangegenuinely does something now. - On `/competitors`, `null` never means zero.
visibility,sentimentPositive, andavgPositioncome backnullwhen there's nothing to measure, and anoteobject tells you which case you're in:NO_ROLLUP_DATA(no analysis runs in the window, or the brand's history isn't built yet),PRE_ROLLUP_EPOCH(the window reaches back further than we hold data for, so ask for a shorter one), orROLLUP_READ_FAILED(a transient read failure on our side, so retry). There's nonoteat all when the window is measured, and in that case anullsentimentPositiveoravgPositionreally does mean that competitor had no sentiment-scored or ranked run. The first two codes carry the same message text as the matching notes on the visibility endpoints;ROLLUP_READ_FAILEDis unique to this one.score,change, andrankare never affected by any of this. - `/competitors` `score` and `change` can be `null`, including for your own brand. A competitor you started tracking after the last analysis run appears right away, but with
scoreandchangenulluntil the next run scores it.brand.scoreisnullon the same rule when your brand has never been analysed, andsummary.brandRankAmongCompetitorsis then0, a "not ranked yet" sentinel rather than a real rank.rankis its position in your live tracked list, ordered byscore(ties broken by name, then id, and unscored competitors last), so adding or removing a competitor renumbers everything immediately. Competitors you've removed are dropped from/competitors, even if an older snapshot still mentioned them./snapshotand the MCPlist_competitorstool read the stored snapshot directly and still show them, which is long-standing behaviour that hasn't changed here. - `/recommendations` is slow, so cache it. It runs a fresh model call per suggestion and can take 8 to 15 seconds. Store the result on your side instead of calling it on every page load.
Who can use it
The REST API requires an active paid subscription on any tier. It isn't available on free, no-subscription workspaces.
Local tracking
Local tracking has dedicated endpoints for existing prompts and follows your brand's rollout and plan availability. Standard snapshots, visibility scores and prompt metrics stay separate from local results.
| Endpoint | Method | Purpose |
|---|---|---|
/brands/:brandId/local-tracking | GET | List local configurations |
/brands/:brandId/prompts/:promptId/local-tracking | GET | Read settings, version and historical targets/models |
/brands/:brandId/prompts/:promptId/local-tracking/locations | GET | Search selectable city targets |
/brands/:brandId/prompts/:promptId/local-tracking/coverage | GET | Preview model precision and available capacity for a target |
/brands/:brandId/prompts/:promptId/local-tracking | PUT | Enable, change or pause local tracking |
/brands/:brandId/prompts/:promptId/local-tracking/report | GET | Read a target/model's local evidence |
To read results, pass a targetId from settings or its history and a model such as google-ai-mode. Use days=7, 14, 30 or 90 (default 14), or paired from and to timestamps with timezones spanning at most 90 days. Local reports use these parameters instead of time_range.
To configure tracking with a read+write key:
- Read settings and use
configuration.configVersionasexpectedVersion, or0whenconfigurationis null. - Search for a city and preview that target's coverage. Review city, state, country and unavailable model coverage and remaining capacity.
- PUT the
targetId,expectedVersion, the preview'scoverageFingerprint(returned asfingerprint), and a fresh UUIDidempotencyKey. If the preview requires acknowledgement, includeacknowledgeCoverage: trueonly after accepting that coverage. - To pause, PUT
targetId: null, the currentexpectedVersionand a fresh UUID. A pause needs no coverage preview.
Retry an uncertain write with the identical body and idempotency key. A version conflict means reread settings before deciding whether another write is necessary. canManage and canStopTracking reflect the current key's ability to make those changes, including write availability.
Adding city or other location fields to an ordinary prompt create or update request is rejected; create the prompt first, then use this workflow. searchIntent: "LOCAL" only classifies a prompt and does not configure tracking.
No completed analysis means brandAppearances: null, not zero. Label reports with the recorded targeting precision, preserve evidence completeness, and use a shorter window if the report exceeds 300 checks or spans different effective geographies. Local responses are private and marked no-store. Read local tracking limits and troubleshooting for the remaining bounds. The full field reference is available in Settings → Integrations → REST API, including Copy API reference for your agent.
