ChangelogPricing

REST API Reference

The RankZero REST API (/api/v1) - pull per-brand visibility, GSC, and GA4 KPIs server-to-server.

The RankZero REST API exposes the same data as our MCP server over plain HTTP+JSON, so automations and integrations can pull per-brand KPIs server-to-server without speaking MCP.

Base URL

https://www.rankzero.io/api/v1

(Same origin as the RankZero app.)

Authentication

Every request must send a bearer key:

Authorization: Bearer <YOUR_API_KEY>

Keys are issued on request. A key is scoped to one account - it can only read that account's brands (brands the account owns or shares via its organization). There is no all-brands access.

Missing or invalid key → 401.

curl https://www.rankzero.io/api/v1/brands \
  -H "Authorization: Bearer $RANKZERO_API_KEY"

Addressing a brand

The canonical identifier is the brand's id (a stable UUID). Discover your brands and their ids with GET /brands. As a convenience you may also pass a brand's domain or name:

  • Matches exactly one brand → resolves.
  • Matches more than one (e.g. two brands on the same domain) → 409 {"error":"ambiguous brand, use id"}.
  • Matches none (or a brand outside your scope) → 404 {"error":"unknown brand"}.

Reporting period

KPI endpoints accept ?period=, one of 24h, 7d, 30d, 90d, 6m, 12m, 16m. Default 7d.

/ga4 additionally accepts an exact ?from=&to= calendar range (see below). Everywhere else, ?period= is the window.

The long windows (6m, 12m, 16m) are intended for the aggregate Search Console trend - 16m is the full retention Google Search Console keeps. They are reliable for totals-over-time; a dimensioned GSC breakdown (?dimensions=query|page) still caps at 1000 rows per request, so over long spans it truncates rather than paginating. GSC data also lands with a ~2-3 day lag, so the trailing few days of any window are incomplete.


Endpoints

GET /brands

Lists the brands your key can access.

curl https://www.rankzero.io/api/v1/brands \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
[
  { "id": "3f8a1c2e-9b4d-4e7a-bc1f-2d6e5a0b9c11", "name": "Tesla", "domain": "tesla.com" }
]

GET /brands/{brand}/kpis

Headline snapshot: visibility, Google Search Console totals, and GA4 sessions by source.

curl "https://www.rankzero.io/api/v1/brands/tesla.com/kpis?period=7d" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
{
  "brand": "Tesla",
  "period": "7d",
  "tag": null,
  "asOf": "2026-06-08T12:00:00.000Z",
  "visibility": { "percent": 11.4, "rank": 8 },
  "gsc": { "clicks": 31, "impressions": 1606, "avgPosition": 12.4, "ctr": 0.019 },
  "ga4": {
    "sessionsBySource": [
      { "source": "chatgpt.com", "sessions": 3, "aiSessions": 3, "ai": true, "aiSource": "ChatGPT" },
      { "source": "google", "sessions": 22, "aiSessions": 0, "ai": false, "aiSource": null }
    ],
    "totalSessions": 42,
    "aiSessions": 3
  }
}
  • gsc / ga4 are null when that integration isn't connected for the brand (visibility is still returned).
  • ga4.sessionsBySource is unfiltered - every traffic source is returned - but each row carries our own verdict: aiSessions is the part of that source's traffic that counts as AI search, ai is aiSessions > 0, and aiSource names the assistant ("ChatGPT", "Meta AI") or is null. The two session counts differ when one source arrives by several mediums - an ad click from chatgpt.com is paid, not AI search. You can still apply your own allowlist to the raw source if you prefer.
  • gscWindow is the range the Search Console figures cover; it ends on the last settled day, so it is not "the last 7 days from today" the way visibility and GA4 are.
  • ?compare=previous adds gscPrev and comparePeriod for the same-length preceding window. Search Console only: visibility and GA4 stay single-window. For per-row comparisons use /gsc?dimensions=…&compare=previous.
  • ?tag=<tag> narrows visibility only to active prompts carrying that tag. gsc and ga4 measure the whole site and have no prompt set behind them, so they are unchanged by it: a tagged response mixes the two scopes, and echoes tag so you can tell which reading applies. See /overview for what a tagged percentage does and does not mean.

GET /brands/{brand}/overview

Own-brand visibility plus the full industry ranking (competitors).

{
  "brand": "Tesla",
  "period": "7d",
  "tag": null,
  "asOf": "2026-06-08T12:00:00.000Z",
  "visibility": { "percent": 11.4, "rank": 8 },
  "ranking": [
    { "name": "Tesla", "url": "tesla.com", "isOwnBrand": true, "rank": 8, "visibilityPercent": 11.4, "sentiment": 62 }
  ]
}
  • ?tag=<tag> narrows the ranking to the runs of active prompts carrying that tag. tag is echoed back, null when you did not send one. Use /prompts?tag=<tag> to list the exact prompts a tagged figure was computed over.
  • A tagged visibilityPercent is a share within that prompt set, not that tag's contribution to the brand-wide figure. Tag percentages therefore do not sum to the untagged one, and a tag with a handful of prompts is noisy.
  • The filter reads a prompt's tags at query time, not at run time, so retagging a prompt moves its history in these numbers too.

GET /brands/{brand}/gsc

Google Search Console totals for the period. window is the range the numbers actually cover: Search Console settles ~2-3 days behind, so the window ends on the last settled day, not today. Label these numbers with window, not period.

{
  "brand": "Tesla",
  "period": "7d",
  "window": { "startDate": "2026-06-01", "endDate": "2026-06-07" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "gsc": { "clicks": 31, "impressions": 1606, "avgPosition": 12.4, "ctr": 0.019 }
}

Breakdown by dimension. Add ?dimensions= (comma-separated; one or more of query, page, date, country, device, searchAppearance) to get per-row performance instead of totals - the raw material for opportunity scans (near-miss queries at position 11–20, high-impression/low-CTR pages). Optional ?limit (default 1000). Each row carries the requested dimension(s) plus clicks, impressions, ctr, position. rows is null if GSC isn't connected.

curl "https://www.rankzero.io/api/v1/brands/tesla.com/gsc?dimensions=query&period=30d&limit=1000" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "startDate": "2026-05-09", "endDate": "2026-06-07" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "dimensions": ["query"],
  "rows": [
    { "query": "electric suv range", "clicks": 12, "impressions": 480, "ctr": 0.025, "position": 13.4 }
  ]
}

Period over period. Add ?compare=previous to get the same-length window immediately before the current one alongside it. The two windows are equal length and share no day, so the delta between them is real.

On totals it adds gscPrev and comparePeriod - the true site-wide week-over-week:

curl "https://www.rankzero.io/api/v1/brands/tesla.com/gsc?period=7d&compare=previous" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
{
  "brand": "Tesla",
  "period": "7d",
  "window": { "startDate": "2026-06-01", "endDate": "2026-06-07" },
  "comparePeriod": { "startDate": "2026-05-25", "endDate": "2026-05-31" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "gsc": { "clicks": 31, "impressions": 1606, "avgPosition": 12.4, "ctr": 0.019 },
  "gscPrev": { "clicks": 24, "impressions": 1402, "avgPosition": 13.1, "ctr": 0.017 }
}

On a dimensioned call every row carries its previous-window counterparts instead:

curl "https://www.rankzero.io/api/v1/brands/tesla.com/gsc?dimensions=query&period=7d&compare=previous" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
{
  "dimensions": ["query"],
  "rows": [
    {
      "query": "electric suv range",
      "clicks": 12, "impressions": 480, "ctr": 0.025, "position": 13.4,
      "clicksPrev": 4, "impressionsPrev": 210, "positionPrev": 18.2
    }
  ]
}
  • Emerging queries are impressionsPrev === 0 && impressions > 0: present now, absent before.
  • Rows are current-window rows only. A query that ranked last week and is gone this week has no row to hang on, so it will not appear; use two single-window calls if you need the disappearances.
  • A *Prev of 0 means "no row in the previous window", which for positionPrev is did not rank, not ranked first. Filter those out before averaging positions.
  • With date among the dimensions, the previous window is aligned day for day: day N of the window is compared with day N of the window before it. (Until 2026-08-27 a date breakdown reported every *Prev as 0, because the two windows never share a date and the rows were matched on the date itself.)
  • Summed breakdown rows undercount the site-wide totals - Search Console anonymizes rare queries - so the honest period-over-period headline is the totals compare above, not the sum of the rows.

GET /brands/{brand}/ga4

GA4 sessions broken down by source, each row labelled with whether it counts as AI search. window is the date range the numbers cover: GA4 has no reporting lag, so it is simply period applied to today (or the explicit range described below).

{
  "brand": "Tesla",
  "period": "7d",
  "window": { "startDate": "2026-06-02", "endDate": "2026-06-08" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "ga4": {
    "sessionsBySource": [
      { "source": "chatgpt.com", "sessions": 3, "aiSessions": 3, "ai": true, "aiSource": "ChatGPT" },
      { "source": "google", "sessions": 22, "aiSessions": 0, "ai": false, "aiSource": null }
    ],
    "totalSessions": 42,
    "aiSessions": 3
  }
}

Every source is returned, AI or not. The classification is the same one behind aiSessions in the daily series and the AI splits on ?keyEvents=true, so the three agree. Per row, sessions is all of that source's traffic and aiSessions the part that counts as AI - they differ when a source arrives by several mediums, since an ad click from chatgpt.com is paid rather than AI search. ai is aiSessions > 0 and aiSource is the assistant's display name, null otherwise. The row aiSessions sum to the top-level aiSessions.

Daily series. Add ?dimensions=date for one row per day instead of the period total - the series behind an organic/AI traffic chart. Each row carries sessions (all traffic), organicSessions and aiSessions, using the same audience definitions as ?keyEvents=true and the rest of RankZero: AI Search is GA4's own AI Assistant channel plus our allowlist for the assistants that channel does not cover, Organic Search is GA4's own channel plus the search engines GA4 leaves out of it. The two are disjoint, and neither counts Google's AI Overviews or AI Mode as AI: Google defines those as Organic Search and sends nothing on the destination URL that would separate them from an ordinary organic click. Rows are zero-filled, so an N-day window always returns exactly N rows, ascending by date. rows is null if GA4 isn't connected.

curl "https://www.rankzero.io/api/v1/brands/tesla.com/ga4?dimensions=date&period=30d" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "startDate": "2026-05-10", "endDate": "2026-06-08" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "dimensions": ["date"],
  "rows": [
    { "date": "2026-05-10", "sessions": 412, "organicSessions": 191, "aiSessions": 3 }
  ]
}

Event breakdown. Add ?dimensions=eventName for one row per event name the property received over the window, with the same three audience splits: count (all traffic), organicCount and aiCount. Rows come back sorted by count, descending, and rows is null if GA4 isn't connected.

curl "https://www.rankzero.io/api/v1/brands/tesla.com/ga4?dimensions=eventName&period=30d" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "startDate": "2026-05-10", "endDate": "2026-06-08" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "dimensions": ["eventName"],
  "rows": [
    { "eventName": "page_view", "count": 20382, "organicCount": 2896, "aiCount": 41 },
    { "eventName": "calendly_appointment", "count": 4, "organicCount": 1, "aiCount": 0 }
  ]
}

This is deliberately not the same question as ?keyEvents=true. That reports what someone has marked as a key event inside GA4, so a property can be collecting a booking on every visit and still report zero conversions, and nothing in the response distinguishes "no conversions happened" from "nobody marked one". ?dimensions=eventName shows what the property is actually receiving, which is what tells the two apart. Use keyEvents for reporting and eventName when you need to check that the reporting is measuring anything at all.

date and eventName are the dimensions this endpoint takes; anything else is a 400, and they cannot be combined. A dimensioned response carries rows instead of ga4 (same as /gsc), so ask without ?dimensions when you want the by-source totals. Either one composes with ?keyEvents=true, which stays its own keyEvents block.

Exact date range. ?from=YYYY-MM-DD&to=YYYY-MM-DD replaces ?period= with a calendar range, so a monthly report can pull the month on its own masthead instead of a rolling 30 days that drifts every time it is re-rendered. Both bounds are inclusive, they must be given together, and the range may span at most 400 days (GA4 quota behaves differently from the date-aggregated Search Console path, so there are no 6m/12m/16m windows here). to is clamped to today, and period comes back null because the window is no longer a rolling one - read window.

The range applies to the whole endpoint, ?keyEvents=true included, so revenue for a named month is one call:

curl "https://www.rankzero.io/api/v1/brands/tesla.com/ga4?from=2026-05-01&to=2026-05-31&keyEvents=true" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
  • Daily rows and sessionsBySource are separate GA4 reports, so their session totals can differ by a fraction of a percent over the same window. GA4 counts sessions per dimension combination; neither number is wrong, they answer slightly different questions. Don't derive one from the other.
  • Cost and pacing. GA4's limit is concurrency, not volume: each report costs one or two tokens of a property's 40,000 per hour, but only ten requests may be in flight against one property at a time. ?dimensions=date&keyEvents=true is nine reports, so this API paces its own calls per property and retries the concurrency error rather than letting a single request exhaust the property. Pull different brands in parallel as fast as you like; keep repeated calls against one brand sequential, and expect a second or two for the nine-report form.

What is hiding in direct traffic. Add ?directEstimate=true for directEstimate, which asks how much unattributed traffic could be AI whose referrer was stripped - links opened from the ChatGPT apps, copy-pasted URLs, anything behind rel="noreferrer".

{
  "directEstimate": {
    "direct": 6436, "homeOrUnknown": 2195, "deep": 4241,
    "rate": 53.0, "organicRate": 69.6, "excess": 0, "aiPages": 43
  }
}

rate is the share of placeable direct sessions that landed on one of the aiPages pages AI assistants cite. On its own that looks like an AI signal, so organicRate is the control: the same share for Organic Search, whose referrer is intact and which therefore contains no disguised AI.

excess is the only number worth quoting - the sessions by which rate outruns organicRate. It is 0 on most properties, and that is the finding rather than a failure: measured across live properties, organic lands on AI-cited pages more often than direct does on 13 of 17, because those are simply the site's content pages. excess is also forced to 0 when the comparison cannot carry weight - too little organic traffic to be a control, a page set so broad it separates nothing, or a gap inside the noise floor.

An estimate, never part of aiSessions, and no substitute for the attribution tag. Opt-in: three extra GA4 reports.

Key events, conversion rates, and revenue. Add ?keyEvents=true to also get the property's GA4 key events (what GA4 called conversions before 2024) by event name, plus per-audience conversion rates and revenue - so you can answer "do AI referrals convert, and what are they worth", not just "did they land". It costs six extra GA4 reports, so it is opt-in.

curl "https://www.rankzero.io/api/v1/brands/tesla.com/ga4?period=30d&keyEvents=true" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "startDate": "2026-05-10", "endDate": "2026-06-08" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "ga4": { "sessionsBySource": [ { "source": "chatgpt.com", "sessions": 3, "aiSessions": 3, "ai": true, "aiSource": "ChatGPT" } ], "totalSessions": 42, "aiSessions": 3 },
  "keyEvents": {
    "currency": "EUR",
    "rows": [
      {
        "event": "purchase",
        "keyEvents": 18, "users": 16, "sessions": 17, "sessionRate": 0.0121, "revenue": 4210.5,
        "aiKeyEvents": 4, "aiUsers": 4, "aiSessions": 4, "aiSessionRate": 0.0308, "aiRevenue": 980,
        "organicKeyEvents": 9, "organicUsers": 8, "organicSessions": 9, "organicSessionRate": 0.0164, "organicRevenue": 2110.25
      }
    ],
    "all":     { "sessions": 1402, "users": 1180, "keyEvents": 24, "sessionRate": 0.0157, "revenue": 4210.5, "totalRevenue": 4210.5, "transactions": 18, "revenuePerSession": 3 },
    "ai":      { "sessions": 130,  "users": 121,  "keyEvents": 5,  "sessionRate": 0.0308, "revenue": 980,    "totalRevenue": 980,    "transactions": 4,  "revenuePerSession": 7.54 },
    "organic": { "sessions": 548,  "users": 470,  "keyEvents": 12, "sessionRate": 0.0201, "revenue": 2110.25,"totalRevenue": 2110.25,"transactions": 9,  "revenuePerSession": 3.85 }
  }
}
  • Conversion rate. Per row, sessions is the sessions that contained the event and sessionRate is that over the audience's total sessions - so aiSessionRate is this event's conversion rate for AI traffic. On the audience blocks, sessionRate is GA4's own sessionKeyEventRate: the share of that audience's sessions containing any key event. ai.sessionRate vs organic.sessionRate is the headline "AI visitors convert at X% vs organic at Y%" - check ai.sessions first, since a handful of sessions makes the rate noise. Rates are fractions (0-1), like gsc.ctr.
  • Revenue (ecommerce). ai.revenue and organic.revenue are the ecommerce purchase revenue from those sessions, with transactions (purchases) and revenuePerSession alongside; totalRevenue also counts subscription and ad revenue. Per row, aiRevenue / organicRevenue attribute revenue to that key event, so a purchase row shows what AI traffic actually bought. All amounts are in the property's currency, and are 0 on properties without ecommerce tracking.
  • all / ai / organic share one definition of the audience with the rest of RankZero. AI Search is GA4's own AI Assistant channel plus our allowlist for the assistants it does not yet cover. Organic Search is GA4's own Organic Search channel plus the engines GA4 omits from it: GA4 only counts a source as organic if it is on Google's own search-source list or the medium is exactly organic, so Brave, Startpage, Kagi, Mojeek, Swisscows, MetaGer, Petal, Presearch and Qwant arrive as referrals and would otherwise fall into neither audience. Brave alone is 21k sessions across our properties.
  • The two are disjoint: an assistant that arrives tagged medium=organic (GA4 files it under Organic Search) counts as AI only, so ai and organic never double-count the same session.
  • Google's AI Overviews and AI Mode count as Organic Search, per Google's own definition - there is no destination-side signal that separates them from an ordinary organic click. Brave is the same: its AI answers and its web results arrive under one referrer, since search.brave.com sends strict-origin-when-cross-origin and the path never reaches you. Both sit in organic, not ai.
  • keyEvents is null when GA4 isn't connected, and rows is empty when the property has no events marked as key events in GA4 (Admin → Events → Mark as key event). If every event is marked, the audience sessionRate saturates at 1 - that is the property's configuration, not the API.

GET /brands/{brand}/competitors

The brand's tracked competitors.

{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "competitors": [
    { "id": "…", "name": "Rivian", "url": "https://rivian.com", "isOwnBrand": false }
  ]
}

GET /brands/{brand}/citations

Sources cited in AI answers, with content-type categorization. Accepts ?period, ?limit (default 50), ?country, ?provider (chatgpt|gemini|perplexity|anthropic|google_ai_overview).

{
  "brand": "Tesla",
  "period": "30d",
  "asOf": "2026-06-08T12:00:00.000Z",
  "sources": [
    {
      "source_url": "https://www.youtube.com/watch",
      "page_title": "…",
      "domain": "youtube.com",
      "usage_pct": 22.22,
      "avg_citations": 1,
      "prompt_texts": ["best electric SUV"],
      "content_type": "video"
    }
  ]
}

GET /brands/{brand}/mentions

Every prompt run in the period, newest first, with the competitors (own brand included) that its answer named. Accepts ?period, ?page (default 1) and ?pageSize (default 50, max 500; ?limit is the older name for the same thing).

count is the total number of runs in the period, not the size of this page - so "named in N of M AI answers" is answerable: M is count, N is what you count across the pages. window.from is the resolved lower bound the rows were taken from.

curl "https://www.rankzero.io/api/v1/brands/tesla.com/mentions?period=30d&page=1&pageSize=50" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "from": "2026-05-09T12:00:00.000Z", "to": "2026-06-08T12:00:00.000Z" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "page": 1,
  "pageSize": 50,
  "count": 1284,
  "mentions": [
    {
      "prompt_id": "…",
      "run_id": "…",
      "prompt_text": "best electric SUV",
      "model": "chatgpt",
      "output_preview": "…",
      "brands_mentioned_name": ["Tesla", "Rivian"],
      "brands_mentioned_url": ["tesla.com", "rivian.com"],
      "brands_mentioned_ids": ["…", "…"],
      "run_at": "2026-06-08T06:12:41.000Z",
      "country": "United States"
    }
  ]
}
  • period is the window. One row per run, so a longer period really does return more: count grows with it and the pages reach further back. (Before 2026-08-27 this endpoint returned the latest run per prompt and capped at 50 rows undocumented, which made every period return the same slice.)
  • brands_mentioned_* are the brand's tracked competitors, own brand included; isOwnBrand per competitor comes from /competitors.
  • Rows are ordered by run_at descending, run_id breaking ties, so walking ?page=1..n sees every run once and none twice even when a batch lands in the same instant. Page 1 is the newest runs whatever the period, since every window ends now; it is the later pages that reach further back.
  • A page past the last one is an empty mentions array with the real count, not an error, so you can page until it comes back empty.
  • A run whose answer was never stored is neither listed nor counted.

GET /brands/{brand}/prompts

The brand's tracked prompts - the ones being monitored (paginated, 50/page). Accepts ?page, ?search, ?isActive, ?tag. Distinct from the suggestion queue below.

?isActive selects the set: true (the default, so callers written before this parameter existed see no change), false, or all. Any other value is a 400 rather than a silent fall back to the default.

?tag returns only the prompts carrying that tag, matching the same way ?tag does on /kpis and /overview, so you can list the exact prompt set a tagged visibility figure was computed over. Tags are free text with no fixed vocabulary, so an unknown tag is an empty page rather than an error.

source records where each prompt came from: manual, ai_generated, own_brand, or a suggestion_* origin (suggestion_gsc, suggestion_bing, suggestion_paa, suggestion_generated, suggestion_rankzero, suggestion_perplexity) when it was promoted from the suggestion queue. intents / tags are the prompt's classification (may be null).

{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "page": 1,
  "pageSize": 50,
  "count": 15,
  "prompts": [
    {
      "id": "…",
      "text": "best electric SUV",
      "country": "United States",
      "isActive": true,
      "source": "suggestion_bing",
      "intents": ["commercial"],
      "tags": null,
      "createdAt": "…"
    }
  ]
}

POST /brands/{brand}/prompts

Creates tracked prompts. The body takes an array, so a batch is one call.

curl -X POST "https://www.rankzero.io/api/v1/brands/tesla.com/prompts" \
  -H "Authorization: Bearer $RANKZERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "prompts": [
        { "text": "best electric SUV", "country": "US", "tags": ["commercial"], "isActive": true }
      ] }'
  • text is required (max 500 characters).
  • country defaults to the brand's own country, not a global default. A value you send has to resolve to a real country: US, us and United States all work, Atlantis is rejected rather than quietly stored.
  • tags optional (max 20 per prompt), isActive optional and defaults to true.
  • source is set by the server, never by the caller. It currently records manual, so API-created prompts are not distinguishable from dashboard entries yet; that needs a prompt_source enum value.
  • Max 100 prompts per request.

Per-item, not all-or-nothing: a batch where three of twenty already exist creates seventeen and names the three in errors.

{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "created": 1,
  "prompts": [ { "id": "…", "text": "best electric SUV", "country": "United States", "isActive": true, "source": "manual", "intents": ["commercial"], "tags": ["commercial"], "createdAt": "…" } ],
  "activePrompts": { "used": 16, "max": 25, "remaining": 9 },
  "errors": [
    { "text": "best electric car", "error": "already tracked for this brand and country", "existingPromptId": "…" }
  ]
}
  • Duplicates are matched on brand + country + case-insensitive text, including inactive prompts. A prompt you deactivated last month is reported with its existingPromptId rather than recreated as a second row, so re-running against an overlapping candidate set is safe. PATCH that id active if you want it back.
  • activePrompts is your plan budget after the call, so a client can throttle itself.
  • A batch that is entirely duplicates is a 200 with created: 0, not an error: that is the ordinary result of re-running. A body with nothing usable in it is a 400, and carries the same errors list.

The plan's active-prompt limit is the one thing that fails the whole call. Exceeding it returns 409 with the numbers rather than creating some and dropping the rest:

{
  "error": "Active prompt limit reached: 24 of 25 active on the starter plan, 6 more requested.",
  "plan": "starter", "limit": 25, "current": 24, "requested": 6
}

Creating prompts with isActive: false does not spend the budget.

PATCH /brands/{brand}/prompts

Two body shapes, one endpoint. Send one or the other, never both.

Bulk status, for retiring and restoring prompts:

{ "promptIds": ["…", "…"], "isActive": false }

Per-prompt edits, for tags and country:

{ "updates": [
    { "id": "…", "addTags": ["permits-commercial"], "removeTags": ["unclustered"] },
    { "id": "…", "tags": ["only-these"], "country": "DE" },
    { "id": "…", "isActive": true }
] }
  • tags replaces the set; addTags / removeTags amend it. Sending both for one prompt is rejected, because the two have no defensible order. Prefer amending: a replace built from a value you read earlier will clobber any tag added in the dashboard meanwhile. "tags": [] clears every tag.
  • country follows the same recognition rule as create.
  • Ids are resolved within the addressed brand only. An id belonging to another brand comes back as unknown prompt for this brand and is not written.
  • Naming the same id twice in one call rejects the second, so two conflicting edits cannot race inside one request.
  • Activating prompts spends plan budget and can return the same 409 as create. Deactivating never does.

Response mirrors create: updated, the prompts as written, activePrompts, and per-item errors. A call where nothing matched is a 400 that still lists why each id was refused.

GET /brands/{brand}/prompt-suggestions

The suggestion queue - questions you could add to tracking, distinct from the tracked prompts above. Accepts ?type, ?status (single value or comma-separated).

This queue spans every engine: suggestionType identifies where each came from - direct_conversion_gsc / direct_conversion_bing (queries the site converts on, per engine), related_keywords_bing (Bing keyword research), and PAA / generated / Perplexity variations. source.impressions (and clicks/ctr/position where the engine returns them) carry the demand behind each. So Bing keyword suggestions come out here, alongside GSC and the rest - filter to Bing with ?type=direct_conversion_bing,related_keywords_bing.

{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "count": 12,
  "suggestions": [
    {
      "id": "…",
      "promptText": "which electric SUV has the longest range",
      "suggestionType": "direct_conversion_bing",
      "status": "pending",
      "anchorQuery": "electric SUV range",
      "confidenceScore": 0.82,
      "sourceQuery": "longest range electric suv",
      "country": "United States",
      "source": { "clicks": 12, "impressions": 480, "ctr": 0.025, "position": 8.3 },
      "createdAt": "…"
    }
  ]
}

GET /brands/{brand}/bing

The brand's Bing Webmaster integration - the Bing counterpart to /gsc, plus the crawl/index health GSC exposes per URL. Bing is the demand signal AI assistants ground on (Copilot and ChatGPT search lean on it), so everything Bing lives here:

  • queries - the site's Bing query performance, rolled up to one row per query with the same shape as /gsc's query breakdown (clicks, impressions, ctr, position; clicks + impressions summed, CTR derived, position averaged weighted by impressions), sorted by impressions. The demand the site already earns - Bing's /gsc analog. Fetched live; present only when a Bing site is connected.
  • related - keyword research. Populated only when you pass ?seed=<keyword>: a live Bing expansion of the seed into related keywords with impression volume (sorted by volume), surfacing demand the site doesn't yet rank for. Optional ?country= (lowercase alpha-2, default us) and ?language= (full locale, default en-US); the volume window is the trailing 30 days. Empty without a seed, or when not connected.
  • crawlStats: the freshest daily crawl breakdown, fetched live: inIndex, crawledPages, code2xx / code4xx / code5xx, blockedByRobotsTxt, containsMalware, crawlErrors, date. inIndex (pages in Bing's index) is the aggregate index count; per-URL coverage is in indexStatus. null when not connected or Bing returns no data.
  • crawlIssues - one row per (url, issue): { url, label, severity } (404s, blocked, malware, redirects…). Fetched live; empty when clean or not connected.
  • indexStatus: live per-URL index coverage. RankZero takes the brand's sitemap URLs and inspects a page of them against Bing's index, returning one row per URL: { url, indexed, lastCrawl?, reason? }. indexed is tri-state: true (Bing reports a crawl date, lastCrawl, the only per-URL index signal Bing exposes), false (a confident not-indexed verdict, reason e.g. Not in Bing index, Discovered, not yet crawled), or null (unknown: the live lookup was rate-limited or failed, reason lookup throttled / lookup failed). Never treat null as not-indexed, it is a transient artifact, not a gap. indexedCount / notIndexedCount / unknownCount cover the returned page and count only their own verdict; count is the full sitemap size. Paginate with ?page (default 1) and ?limit (default 25, max 100). Use it to find non-indexed live URLs, then submit them via /bing/submit. null when not connected.

Bing keyword suggestions are not here - they live in /prompt-suggestions alongside every other engine (filter with ?type=direct_conversion_bing,related_keywords_bing).

Not connected → connected: false, empty/null live fields, 200 - never an error. seed echoes the requested seed (null if none).

# The brand's Bing integration (query impressions + crawl/index health)
curl "https://www.rankzero.io/api/v1/brands/tesla.com/bing" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"

# ...plus live keyword research expanded from a seed
curl "https://www.rankzero.io/api/v1/brands/tesla.com/bing?seed=electric%20suv&country=us&language=en-US" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"

# ...page through per-URL index coverage
curl "https://www.rankzero.io/api/v1/brands/tesla.com/bing?page=1&limit=100" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "connected": true,
  "siteUrl": "https://tesla.com/",
  "seed": "electric suv",
  "queries": [
    { "query": "electric suv range", "clicks": 34, "impressions": 1240, "ctr": 0.027, "position": 6.8 }
  ],
  "related": [
    { "query": "best electric suv 2026", "impressions": 8400 },
    { "query": "electric suv with longest range", "impressions": 3100 }
  ],
  "crawlStats": {
    "inIndex": 4120,
    "crawledPages": 5300,
    "code2xx": 5100,
    "code4xx": 140,
    "code5xx": 12,
    "blockedByRobotsTxt": 48,
    "containsMalware": 0,
    "crawlErrors": 152,
    "date": "2024-06-07T00:00:00.000Z"
  },
  "crawlIssues": [
    { "url": "https://tesla.com/old", "label": "Client error (4xx)", "severity": "error" }
  ],
  "indexStatus": {
    "asOf": "2026-06-08T12:00:00.000Z",
    "page": 1,
    "pageSize": 25,
    "count": 5300,
    "indexedCount": 22,
    "notIndexedCount": 2,
    "unknownCount": 1,
    "urls": [
      { "url": "https://tesla.com/model-y", "indexed": true, "lastCrawl": "2026-06-07T04:12:00.000Z" },
      { "url": "https://tesla.com/new-page", "indexed": false, "reason": "Not in Bing index" },
      { "url": "https://tesla.com/model-s", "indexed": null, "reason": "lookup throttled" }
    ]
  }
}

POST /brands/{brand}/bing/submit

Submit live URLs to Bing for (re)crawl through the brand's existing Bing Webmaster connection, the IndexNow equivalent, with no per-site {key}.txt files. Pair it with /bing's indexStatus: page through to find indexed: false URLs, then submit them here.

Body: { "urls": string[] } (http(s) URLs; duplicates and malformed entries are dropped into errors). Auth and brand resolution are identical to the other /bing routes.

Respects Bing's remaining daily submission quota: URLs beyond it are not sent and come back in errors as daily submit quota exhausted. Response:

  • submitted: count actually sent to Bing.
  • quotaRemaining: daily quota left after this call (omitted when Bing doesn't report a quota).
  • errors: one { url?, error } per dropped/failed URL (invalid input, quota, or a Bing error message).

A brand with no Bing connection returns a clean 409 { "error": "brand not connected to Bing" }. An empty/invalid body or no valid URLs returns 400.

curl -X POST "https://www.rankzero.io/api/v1/brands/tesla.com/bing/submit" \
  -H "Authorization: Bearer $RANKZERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "urls": ["https://tesla.com/new-page", "https://tesla.com/model-y"] }'
{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "submitted": 2,
  "quotaRemaining": 8,
  "errors": []
}

Searches the RankZero knowledge base (docs, glossary, blog). Not brand-scoped. Requires ?query (?q also accepted); optional ?locale.

{
  "query": "visibility",
  "results": [
    { "id": "/docs/metrics/visibility", "type": "page", "url": "/docs/metrics/visibility", "content": "…" }
  ]
}

Errors

Errors are flat JSON: { "error": "<message>" }. Write endpoints add fields alongside error: an errors array naming each refused input, and, on a plan limit, the numbers behind the refusal.

StatusMeaning
400Malformed body, or nothing in it was usable. On a write, errors says which input failed and why.
401Missing/invalid API key (or API not configured).
404unknown brand - not found, or outside your key's scope.
409ambiguous brand, use id - a domain/name matched more than one brand. Also the plan's active-prompt limit, with plan, limit, current and requested.
500internal error.