API Quick Start
Create an API key, make your first three calls, and see the same task written in curl, Python, and TypeScript.
This page takes you from no credential to a useful answer. It assumes nothing except that you can already sign in to VitalSentinel.
1. Create an API key
API keys are created in the dashboard. The public API has no key management surface at all, deliberately: a credential must not be able to mint another credential.
- Go to Settings → API access. The page manages keys for the currently active workspace, so switch workspace first if you have more than one.
- You need the owner or admin role. Members can see the list but cannot add to it.
- Click Create API key and fill in:
- Name - free text, shown in the key list and the audit log. For example,
Reporting script. - Scopes - grouped into Read, Configure, and Run tiers. For this guide, pick
read:overviewandread:domains. - Expires (optional) - blank means the key never expires.
- IP allowlist (optional) - addresses or CIDR ranges, for example
203.0.113.4or203.0.113.0/24.
- Name - free text, shown in the key list and the audit log. For example,
- Copy the secret.
The secret is shown exactly once. VitalSentinel stores only a SHA-256 digest of it. The key list afterwards shows only the first 16 characters, so you can tell keys apart but cannot recover one you failed to save.
A production secret looks like vsk_live_ followed by 43 characters. Non-production environments issue vsk_dev_ keys, so a development key cannot be pasted into a production client and silently work.
2. Authenticate
Send the secret as a bearer token:
export VITALSENTINEL_API_KEY="vsk_live_..."
curl -sS https://app.vitalsentinel.com/api/public/v1/health \
-H "Authorization: Bearer $VITALSENTINEL_API_KEY"That is the only accepted mechanism. Session cookies are never accepted, and a key in a query string would end up in access logs and browser history, so it is not supported either.
3. Ask who you are: GET /me
Call this first from any new client. It answers "who am I, what can I actually do, and against which workspaces" in one round trip.
curl -sS https://app.vitalsentinel.com/api/public/v1/me \
-H "Authorization: Bearer $VITALSENTINEL_API_KEY"{
"kind": "api_key",
"credential_id": "6f1d0f5c-2a3b-4c1d-9e8f-0a1b2c3d4e5f",
"user_id": "3b9f7a11-8c42-4d6e-9a03-5f2e1c7b8d90",
"workspace_ids": ["e0d4a2c8-1b77-4f39-9c52-6a8d3e0f4b21"],
"scopes": ["read:domains", "read:overview"],
"effective_scopes": ["read:domains", "read:overview"],
"roles": { "e0d4a2c8-1b77-4f39-9c52-6a8d3e0f4b21": "admin" }
}Build your client's capability list from effective_scopes, never from scopes. See Scopes for why the two can differ.
4. Find a domain
curl -sS https://app.vitalsentinel.com/api/public/v1/domains \
-H "Authorization: Bearer $VITALSENTINEL_API_KEY"{
"items": [
{
"id": "9c2f4d18-5e6a-4b70-8c19-2d3f5a7b9e04",
"workspace_id": "e0d4a2c8-1b77-4f39-9c52-6a8d3e0f4b21",
"domain": "example.com",
"active_modules": ["uptime", "crux", "gsc", "indexing", "robots"],
"created_at": "2026-01-14T09:22:41Z"
}
],
"total": 1,
"has_more": false,
"next_cursor": null
}active_modules reports which modules hold stored configuration for this domain, so you can skip the ones that are not set up. It can contain uptime, crux, synthetic, gsc, indexing, robots, sitemap, alerts, and reports. It reports configured, not has data: a module set up this morning has no data yet, and calling it inactive would send you away from the one thing that just changed. Pass include_modules=false when you are listing many domains and only need identifiers. workspace_id narrows the list to one workspace.
analytics and rum are never listed, and their absence means nothing. Neither has a domain-scoped configuration row to test, because both activate when the tracking script starts reporting. Call those endpoints directly, or GET /domains/{domain_id}/rum/has-data, rather than inferring from this list.
Until 5 August 2026 the field only ever tested uptime, CrUX, and Synthetic Monitoring, so it could not name the other six however the domain was configured. A client that trusted it skipped modules holding substantial data.
/domains and /workspaces return the standard page envelope but always as a single page today, so next_cursor is always null. Write the paging loop anyway: the envelope exists so that pagination can be added later without a breaking change.
Looping over this list is the supported way to operate across all of a customer's domains. The dashboard's bulk fan-out endpoints are deliberately not exposed here.
5. The call actually worth making
GET /domains/{domain_id}/overview returns eight modules in one request, with a comparison against the previous period. It needs read:overview.
curl -sS \
"https://app.vitalsentinel.com/api/public/v1/domains/$DOMAIN_ID/overview" \
-H "Authorization: Bearer $VITALSENTINEL_API_KEY"{
"domain_id": "9c2f4d18-5e6a-4b70-8c19-2d3f5a7b9e04",
"domain": "example.com",
"rum": {
"mobile": { "lcp_ms": 3120.0, "cls": 0.09, "inp_ms": 265.0, "ttfb_ms": 810.0 },
"desktop": { "lcp_ms": 1840.0, "cls": 0.04, "inp_ms": 128.0, "ttfb_ms": 420.0 }
},
"crux": {
"mobile": { "lcp_ms": 2980.0, "cls": 0.11, "inp_ms": 248.0, "ttfb_ms": 900.0 },
"desktop": { "lcp_ms": 1760.0, "cls": 0.05, "inp_ms": 142.0, "ttfb_ms": 460.0 }
},
"uptime": {
"uptime_pct": 99.94,
"ssl_days_remaining": 47,
"domain_days_remaining": 214,
"is_blocked_403": false
},
"gsc": {
"web": { "clicks": 18422, "impressions": 964130, "ctr": 0.0191, "position": 14.7 },
"discover": null,
"news": null
},
"analytics": {
"visitors": 41208,
"pageviews": 96431,
"sessions": 52117,
"bounce_rate_pct": 47.3,
"avg_duration_seconds": 132.6
},
"comparison": {
"gsc": {
"web": {
"clicks": { "current": 18422, "previous": 16903, "change": 1519, "change_pct": 8.99 },
"position": { "current": 14.7, "previous": 15.4, "change": -0.7, "change_pct": -4.55 }
}
},
"analytics": {
"visitors": { "current": 41208, "previous": 38790, "change": 2418, "change_pct": 6.23 }
}
},
"unavailable_modules": [],
"configured_modules": ["uptime", "crux", "gsc", "indexing", "robots"],
"compare_range_start": "2026-06-07T09:41:12Z",
"compare_range_end": "2026-07-05T09:41:12Z",
"meta": {
"range_start": "2026-07-05T09:41:12Z",
"range_end": "2026-08-02T09:41:12Z",
"generated_at": "2026-08-02T09:41:12Z"
}
}Five things in that response are easy to misread:
- The window is fixed at 28 days and is not configurable.
meta.range_startandmeta.range_endreport it so you never have to assume. configured_modulesis what tells a quiet module apart from an absent one. A module named there whose block isnullis set up but reported nothing in this window. A module not named there is not configured at all.rumandanalyticsare never named, because neither has a configuration row to test, so theirnullonly ever means "nothing in this window".- A module that failed is named in
unavailable_modulesinstead, and the other seven still answer. One broken module never fails the call, so check that array before reading anullas "no data". comparisononly covers three modules: CrUX, Search Console, and Web Analytics. The other five report no historical figure, so they are absent rather than given an invented baseline.compare_range_startandcompare_range_endname the preceding 28-day window those figures are measured against, the same pairGET /domains/{domain_id}/healthprints. A negativeposition.changemeans ranking improved, andchange_pctisnullwhen the previous window was zero, because a relative change against zero is undefined.- Blocks come from each module's cache and can be up to an hour old (robots.txt: five minutes), and this endpoint has no freshness override. It is the wrong call for "is the site up right now". Use
GET /domains/{domain_id}/health?freshness=live, which recomputes every module, orGET /domains/{domain_id}/uptime/status, which is never cached.
Units are in the field names: lcp_ms, ttfb_ms, bounce_rate_pct, avg_duration_seconds. cls is a unitless score, ctr is a fraction between 0 and 1 (0.0191 means 1.91%), and all Core Web Vitals figures are the 75th percentile.
The same task in three languages
Task: for every domain the credential can see, print the 28-day Web Analytics visitors and Search Console clicks, with the change against the previous period.
All three snippets do the same four things: send the bearer token, retry once on a 429 using Retry-After, raise on an error response rather than on a bare status code, and tolerate a null module block.
curl
#!/usr/bin/env bash
set -euo pipefail
BASE="https://app.vitalsentinel.com/api/public/v1"
AUTH="Authorization: Bearer $VITALSENTINEL_API_KEY"
# Fetch with one retry that respects Retry-After.
fetch() {
local url="$1" body status retry
body=$(curl -sS -w '\n%{http_code}' -H "$AUTH" "$url")
status=$(printf '%s' "$body" | tail -n1)
if [ "$status" = "429" ]; then
retry=$(curl -sSI -H "$AUTH" "$url" | awk 'tolower($1)=="retry-after:"{print $2+0}')
sleep "${retry:-5}"
body=$(curl -sS -w '\n%{http_code}' -H "$AUTH" "$url")
status=$(printf '%s' "$body" | tail -n1)
fi
if [ "$status" -ge 400 ]; then
printf 'error %s: %s\n' "$status" "$(printf '%s' "$body" | sed '$d')" >&2
return 1
fi
printf '%s' "$body" | sed '$d'
}
# include_modules=false keeps the listing cheap when you only need identifiers.
fetch "$BASE/domains?include_modules=false" \
| jq -r '.items[] | "\(.id)\t\(.domain)"' \
| while IFS=$'\t' read -r id domain; do
fetch "$BASE/domains/$id/overview" | jq -r --arg d "$domain" '
"\($d)\t" +
"visitors=\(.analytics.visitors // "n/a")\t" +
"visitors_change=\(.comparison.analytics.visitors.change_pct // "n/a")%\t" +
"clicks=\(.gsc.web.clicks // "n/a")\t" +
"clicks_change=\(.comparison.gsc.web.clicks.change_pct // "n/a")%"'
donePython
"""Print 28-day visitors and Search Console clicks for every accessible domain."""
import os
import time
import httpx
BASE = "https://app.vitalsentinel.com/api/public/v1"
class VitalSentinelError(RuntimeError):
"""An RFC 9457 problem response. Branch on `type_suffix`, never on `detail`."""
def __init__(self, problem: dict):
self.problem = problem
self.type_suffix = problem.get("type", "").rsplit("/", 1)[-1]
self.status = problem.get("status")
self.request_id = problem.get("request_id")
super().__init__(f"{problem.get('title')}: {problem.get('detail')}")
class Client:
def __init__(self, api_key: str, base: str = BASE):
self._http = httpx.Client(
base_url=base,
headers={"Authorization": f"Bearer {api_key}"},
timeout=60.0,
)
def get(self, path: str, **params) -> dict:
response = self._http.get(path, params=params or None)
# One retry, pacing off Retry-After rather than a fixed sleep.
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", "5")))
response = self._http.get(path, params=params or None)
if response.status_code >= 400:
raise VitalSentinelError(response.json())
return response.json()
def domains(self) -> list[dict]:
"""Page through every accessible domain. Cursors are opaque - pass them back verbatim."""
items, cursor = [], None
while True:
page = self.get("/domains", include_modules=False, cursor=cursor)
items.extend(page["items"])
if not page.get("has_more"):
return items
cursor = page["next_cursor"]
def pct(block: dict | None, metric: str) -> str:
"""A null module block means unconfigured, or configured and quiet. Never an error."""
if not block:
return "n/a"
change = block.get(metric, {}).get("change_pct")
return "n/a" if change is None else f"{change:+.2f}%"
def main() -> None:
client = Client(os.environ["VITALSENTINEL_API_KEY"])
for domain in client.domains():
overview = client.get(f"/domains/{domain['id']}/overview")
if overview.get("unavailable_modules"):
print(f" ! modules unavailable: {', '.join(overview['unavailable_modules'])}")
analytics = overview.get("analytics")
gsc = (overview.get("gsc") or {}).get("web")
comparison = overview.get("comparison") or {}
print(
f"{domain['domain']:<32} "
f"visitors={analytics['visitors'] if analytics else 'n/a':>10} "
f"({pct(comparison.get('analytics'), 'visitors')}) "
f"clicks={gsc['clicks'] if gsc else 'n/a':>10} "
f"({pct((comparison.get('gsc') or {}).get('web'), 'clicks')})"
)
if __name__ == "__main__":
main()TypeScript
/** Print 28-day visitors and Search Console clicks for every accessible domain. */
const BASE = "https://app.vitalsentinel.com/api/public/v1";
interface Problem {
type: string;
title: string;
status: number;
detail: string;
request_id: string;
}
/** Branch on `typeSuffix`, never on the prose in `detail`. */
export class VitalSentinelError extends Error {
readonly typeSuffix: string;
constructor(readonly problem: Problem) {
super(`${problem.title}: ${problem.detail}`);
this.typeSuffix = problem.type.split("/").pop() ?? "unknown";
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function get<T>(path: string, apiKey: string): Promise<T> {
const request = () =>
fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${apiKey}` } });
let response = await request();
// One retry, pacing off Retry-After rather than a fixed sleep.
if (response.status === 429) {
await sleep(Number(response.headers.get("Retry-After") ?? 5) * 1000);
response = await request();
}
if (!response.ok) throw new VitalSentinelError((await response.json()) as Problem);
return (await response.json()) as T;
}
interface Page<T> {
items: T[];
next_cursor: string | null;
has_more: boolean;
total: number | null;
}
interface Domain {
id: string;
domain: string;
}
/** Cursors are opaque base64url. Pass them back verbatim; never parse or construct one. */
async function listDomains(apiKey: string): Promise<Domain[]> {
const all: Domain[] = [];
let cursor: string | null = null;
do {
const query = new URLSearchParams({ include_modules: "false" });
if (cursor) query.set("cursor", cursor);
const page: Page<Domain> = await get(`/domains?${query}`, apiKey);
all.push(...page.items);
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
return all;
}
interface Change {
current: number | null;
previous: number | null;
change: number | null;
change_pct: number | null;
}
interface Overview {
analytics: { visitors: number } | null;
gsc: { web: { clicks: number } | null } | null;
comparison: {
analytics?: { visitors?: Change };
gsc?: { web?: { clicks?: Change } };
} | null;
unavailable_modules: string[];
}
// change_pct is null when the previous window was zero. Do not format it as 0.00%.
const fmt = (change?: Change) =>
change?.change_pct == null
? "n/a"
: `${change.change_pct >= 0 ? "+" : ""}${change.change_pct.toFixed(2)}%`;
async function main(): Promise<void> {
const apiKey = process.env.VITALSENTINEL_API_KEY;
if (!apiKey) throw new Error("VITALSENTINEL_API_KEY is not set");
for (const domain of await listDomains(apiKey)) {
const overview = await get<Overview>(`/domains/${domain.id}/overview`, apiKey);
if (overview.unavailable_modules.length > 0) {
console.warn(` ! modules unavailable: ${overview.unavailable_modules.join(", ")}`);
}
// A null module block means unconfigured, or configured and quiet. Never a failure.
const visitors = overview.analytics?.visitors ?? "n/a";
const clicks = overview.gsc?.web?.clicks ?? "n/a";
console.log(
`${domain.domain.padEnd(32)} ` +
`visitors=${String(visitors).padStart(10)} (${fmt(overview.comparison?.analytics?.visitors)}) ` +
`clicks=${String(clicks).padStart(10)} (${fmt(overview.comparison?.gsc?.web?.clicks)})`,
);
}
}
main().catch((error) => {
if (error instanceof VitalSentinelError) {
console.error(`[${error.typeSuffix}] ${error.message} (request ${error.problem.request_id})`);
process.exit(1);
}
throw error;
});