Developers

Your data, over REST and MCP

Everything the dashboard shows is readable by machine: a REST API for scripts and BI, and an MCP server so an AI assistant can query (and, with scoped OAuth, act on) your visibility data. Both surfaces return byte-identical payloads — including the honesty caveats.

· Last updated

On this page: Quickstart · Authentication · Limits, stated out loud · REST endpoints · Errors · MCP server · Webhooks

Quickstart

  1. Create an API key in Settings → Integrations → API keys (Pro and up). The key is shown once — store it like a password.
  2. Call any endpoint with the key as a bearer token; the first call to make is /v1/sites, which returns the site ids everything else takes.
  3. Or skip REST entirely: point an MCP client at the server below — the same key covers every read tool.

Authentication

Create an API key in Settings → Integrations → API keys (Pro and Agency). Send it as a Bearer header — never in a URL. Keys are account-owner credentials: the owner can see, rotate and revoke every key.

curl https://promvia.app/api/v1/sites \
  -H "Authorization: Bearer pv_live_..."

Limits, stated out loud

One monthly pool covers REST requests and MCP tool calls together: 10,000 requests/month on Pro, 50,000 on Agency, plus a 120 requests/minute burst limit. The pool resets on the 1st (UTC). Checks, scans and generation keep their own product budgets — an API call never spends those; only the actions that actually run them do.

Successful responses and monthly-pool 429s carry X-RateLimit-Limit / -Remaining / -Reset headers, and GET /api/v1/usage returns every allowance on the account in one read. Over the pool you get a 429 that names the limit and the reset date; the separate per-minute burst 429 just asks you to retry shortly.

REST endpoints

Read-only by design (writes live on MCP, behind OAuth scopes). Machine-readable description: openapi.json

EndpointReturns
GET /api/v1/sitesEvery site this key's account can read — the site ids every other call takes.
GET /api/v1/usageYour allowances and what is used: API requests, the check budget (fullChecks.left — full checks left on demand this month, on the engines in fullChecks.engines), generation budget, tracked questions. fullChecks.breakdown says where the month went, in the same full checks: weekly deep checks, daily checks, the share held for automatic checks later this month, checks you started, automatic extras (fact-checking, sentiment, CDN log intake, how-often bands) and what is left, adding up to total (null on a legacy period). engines.selected is the engines your scheduled checks run on (your choice, or your plan's default), engines.daily the ones on the frequent check this month, and engines.sites how many days apart that check runs for each of your sites (checkedEveryDays).
GET /api/v1/sites/{siteId}/visibilityVisibility Score with its components, rank with its basis caveat, share of voice, and the competitor ranking (rivals): each business's share with its sample and 95% interval, and a tie wherever two intervals overlap.
GET /api/v1/sites/{siteId}/citationsLatest per-question state: mentioned or not, cited URLs, sentiment, the other domains in the answer, and demandBand — the question's search demand as a band (rare, occasional, frequent), null when not measured.
GET /api/v1/sites/{siteId}/citations/historyPer-check rows over time, filterable by engine (days 1–90, limit 1–100). observedQueries is the web searches that engine ran for the answer: null where the engine does not expose them, [] when it ran none.
GET /api/v1/sites/{siteId}/evidenceRaw answer excerpts, every cited URL and domain, in-answer position, and observedSearches: the distinct web searches the engines ran for each question, with which engines ran each.
GET /api/v1/sites/{siteId}/sourcesDomains AI cites for your tracked questions, ranked and classified by type.
GET /api/v1/sites/{siteId}/geoLatest GEO readiness audit: grade, score, per-signal findings.
GET /api/v1/sites/{siteId}/geo/actionsTask statuses on the audit's fix list.
GET /api/v1/sites/{siteId}/opportunitiesOpen question opportunities with their scores.
GET /api/v1/sites/{siteId}/prompt-templatesPrompt templates and their dimension variants.
GET /api/v1/sites/{siteId}/scanDeep site scan: run lifecycle plus merged page-level findings.
GET /api/v1/sites/{siteId}/metricsAttributed AI visits and verified revenue, by currency and source.

GET /v1/sites/{siteId}/visibility — payloads carry their caveats with them (the rank's basis field is part of the data, not a footnote):

{
  "site": { "id": "site_1a2b3c", "name": "Acme", "domain": "acme.com" },
  "score": {
    "score": 49,
    "grade": "D",
    "components": [
      { "key": "coverage", "value": 0.44, "weight": 0.3 },
      { "key": "sov", "value": 0.1, "weight": 0.25 },
      { "key": "engines", "value": 0.8, "weight": 0.2 },
      { "key": "geo", "value": 0.81, "weight": 0.15 }
    ]
  },
  "rank": {
    "rank": 4,
    "of": 14,
    "basis": "businesses cited across your discovery questions — you plus every other brand that took an answer slot. Forums, reference sites, press, directories and social are excluded, and questions naming your own brand are not counted"
  },
  "scoreBasis": "A weighted index (0–100) over up to five components; a component with no signal (e.g. sentiment) is left out and the rest renormalized. …",
  "shareOfVoicePct": 10,
  "shareOfVoiceSample": {
    "successes": 4,
    "n": 40,
    "interval95Pct": { "low": 4, "high": 23 },
    "tooThin": false,
    "note": "4 of 40 = 10%. 95% Wilson score interval 4-23%."
  },
  "rivals": [
    {
      "domain": "rival-a.example", "you": false, "rank": 1, "shareOfVoicePct": 23, "versusYou": "tied",
      "shareOfVoiceSample": { "successes": 9, "n": 40, "interval95Pct": { "low": 12, "high": 38 }, "tooThin": false, "note": "9 of 40 = 23%. 95% Wilson score interval 12-38%." }
    },
    {
      "domain": "rival-b.example", "you": false, "rank": 2, "shareOfVoicePct": 18, "versusYou": "tied",
      "shareOfVoiceSample": { "successes": 7, "n": 40, "interval95Pct": { "low": 9, "high": 32 }, "tooThin": false, "note": "7 of 40 = 18%. 95% Wilson score interval 9-32%." }
    },
    {
      "domain": "rival-c.example", "you": false, "rank": 3, "shareOfVoicePct": 13, "versusYou": "tied",
      "shareOfVoiceSample": { "successes": 5, "n": 40, "interval95Pct": { "low": 5, "high": 26 }, "tooThin": false, "note": "5 of 40 = 13%. 95% Wilson score interval 5-26%." }
    },
    {
      "domain": "acme.com", "name": "Acme", "you": true, "rank": 4, "shareOfVoicePct": 10, "versusYou": null,
      "shareOfVoiceSample": { "successes": 4, "n": 40, "interval95Pct": { "low": 4, "high": 23 }, "tooThin": false, "note": "4 of 40 = 10%. 95% Wilson score interval 4-23%." }
    },
    {
      "domain": "rival-d.example", "you": false, "rank": 5, "shareOfVoicePct": 8, "versusYou": "tied",
      "shareOfVoiceSample": { "successes": 3, "n": 40, "interval95Pct": { "low": 3, "high": 20 }, "tooThin": false, "note": "3 of 40 = 8%. 95% Wilson score interval 3-20%." }
    }
  ],
  "rivalsBasis": "The top five businesses by share of voice, ordered by share, with your own row (you: true) always included: appended with its real rank when it is not in the five. Each share is that business's answer slots out of every answer slot a business occupied across your discovery questions, the same denominator as shareOfVoicePct and rank. Forums, reference sites, press, directories and social are excluded, and questions naming your own brand are not counted. Equal counts share a rank. When two businesses' 95% intervals overlap, the data shows no measured lead between them: call it a tie (versusYou: 'tied'), never 'ahead'. A competitor known only by a name you listed has domain null and that name in name. Your own rank is null while you hold no answer slot, as the top-level rank is. Empty when no completed check has ranked anyone."
}

Every rate ships the sample it was measured on. A field ending Pct that is a proportion has a …Sample sibling holding the counts behind it, a 95% Wilson score interval in the same unit, and tooThin — true when n is under 32, the sample size below which a 95% interval can be wider than a third of the scale. It reads n alone, so every rate measured on the same n carries the same tooThin. At that size the rate cannot be read off the sample. 2 of 10 and 200 of 1,000 are both “20%” and are not the same finding. Numbers that are not proportions — the Visibility Score, GEO readiness, an in-answer rank, the visitor-quality lift, money — carry no interval on purpose and say so in a Basis or note field; inventing one would be precision we made up. These fields are additions: nothing named in v1 has been renamed or removed.

Changed on 15 October 2026: tooThin (and the CSV export's _sample_too_small columns) now reads n alone and is true when n is under 32. Before that date it read the width of the observed interval, so more rates report tooThin: true than before.

Errors

Every error is JSON with an error field. The statuses you will meet:

401Missing or invalid API key.
402The account's plan has no API access — Pro and up.
403The key's account now holds a seat in another workspace — API keys are owner credentials; the workspace owner issues their own.
404Site not found, or not this account's.
429Per-minute burst limit or the monthly pool exhausted. A monthly-pool 429 names the limit and reset date in the body and X-RateLimit headers; the burst 429 just asks you to retry shortly.

MCP server

Point any MCP-capable assistant at the endpoint below. With an API key the connection is read-only (13 read tools: sites, allowances, visibility score, citations, history, evidence, sources, GEO audit and actions, opportunities, templates, deep scan, traffic metrics); the 6 write tools — add prompts, start runs and scans, generate briefs — require an OAuth grant with the matching scope, so a leaked key can read but never act.

{
  "mcpServers": {
    "promvia": {
      "url": "https://promvia.app/api/mcp",
      "headers": { "Authorization": "Bearer pv_live_..." }
    }
  }
}

MCP tools

The 13 read tools return the same payloads as the REST endpoints above — both surfaces are built on one shared read model, so they cannot drift apart. An API key covers all of them.

list_sites · get_usage · get_visibility_score · get_citations · get_citation_history · get_evidence · get_sources_report · get_geo_audit · get_geo_actions · get_opportunities · get_prompt_templates · get_deep_scan · get_metrics

The 6 write tools pass through the app's own gate ladders (plan pools, budgets, verification) and ask for an OAuth grant with the named scope — an API key alone stays read-only on purpose.

ToolScopeDoes
add_promptprompts:writeAdds a tracked question; plan pool and duplicate checks apply, same as the dashboard.
archive_promptprompts:writeArchives a tracked question — it stops running and frees its slot.
start_deep_scanruns:writeStarts a deep site scan through the same gate ladder as the dashboard button.
start_citation_runruns:writeStarts a full citation run; it uses checks from this month's allowance, and the burst gates apply.
generate_briefcontent:writeGenerates a content brief for a question; the generation budget applies.
update_action_statusactions:writeSets the status of a GEO action-plan task.

Write scopes: prompts:write, runs:write, content:write, actions:write. Every write runs the same gate ladder as the dashboard button it mirrors — quotas, verification and plan limits included — so nothing is purchasable by API that is not purchasable by click.

Webhooks

Instead of polling, register an HTTPS endpoint in Settings and Promvia POSTs events to it (https:// on the standard port 443 only — a URL naming any other port is refused when you save it and at every delivery): run.completed after every full citation run (with status, won/lost questions, and the visibility score current at delivery time) and citations.changed when questions were won or lost. Deliveries retry with exponential backoff for up to 8 attempts; an endpoint that only fails is disabled automatically and can be re-enabled in Settings.

What a delivery looks like on the wire:

{
  "event": "run.completed",
  "deliveryId": "7f3c9e2a-…",
  "sentAt": "2026-09-01T13:05:12.000Z",
  "site": { "id": "site_1a2b3c", "name": "Acme", "domain": "acme.com" },
  "run": {
    "id": "b41d…", "status": "completed",
    "queriesChecked": 14, "queriesFailed": 0,
    "startedAt": "2026-09-01T13:00:02.000Z"
  },
  "citations": { "won": ["best crm for startups"], "lost": [] },
  "visibilityScore": { "score": 49, "grade": "D", "basis": "current at delivery time" }
}

queriesChecked counts the questions at least one AI answer engine answered in the run; queriesFailed counts the rest. A question only the Bing search proxy returned results for counts as failed — a results page is not an AI answer (since 2026-09-29; before that it counted as checked). The won and lost lists follow the same rule.

Every delivery carries an X-Promvia-Signature header: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with your endpoint secret>. Verify the MAC with a constant-time compare and reject timestamps older than 5 minutes — that is what makes a replayed capture detectable. A webhook.test event with the same signature can be fired from Settings.

// Node.js — verify X-Promvia-Signature
const { createHmac, timingSafeEqual } = require("node:crypto");
function verify(secret, rawBody, header) {
  const m = header.match(/^t=(\d+),v1=([0-9a-f]{64})$/);
  if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const mac = createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest();
  return timingSafeEqual(mac, Buffer.from(m[2], "hex"));
}

run.completed covers the weekly and on-demand checks; the daily check sends citations.changed only when something changed, and the trend check sends nothing — a webhook that fires on every calendar day, changed or not, is noise. The visibilityScore field says "current at delivery time": a retried delivery reports the score as of the retry, never a reconstruction.

What the numbers mean

Payloads carry their own caveats (what a rank is measured against, which pages a scan counted but never fetched) because an assistant reads fields raw and repeats them to your customer. If a number cannot be measured cleanly, the field is null and says so — same rule as the product.