API

API Quick Start

Create an API key, make your first three calls, and see the same task written in curl, Python, and TypeScript.

View as Markdown

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.

  1. Go to SettingsAPI access. The page manages keys for the currently active workspace, so switch workspace first if you have more than one.
  2. You need the owner or admin role. Members can see the list but cannot add to it.
  3. 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:overview and read:domains.
    • Expires (optional) - blank means the key never expires.
    • IP allowlist (optional) - addresses or CIDR ranges, for example 203.0.113.4 or 203.0.113.0/24.
  4. 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:

  1. The window is fixed at 28 days and is not configurable. meta.range_start and meta.range_end report it so you never have to assume.
  2. configured_modules is what tells a quiet module apart from an absent one. A module named there whose block is null is set up but reported nothing in this window. A module not named there is not configured at all. rum and analytics are never named, because neither has a configuration row to test, so their null only ever means "nothing in this window".
  3. A module that failed is named in unavailable_modules instead, and the other seven still answer. One broken module never fails the call, so check that array before reading a null as "no data".
  4. comparison only 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_start and compare_range_end name the preceding 28-day window those figures are measured against, the same pair GET /domains/{domain_id}/health prints. A negative position.change means ranking improved, and change_pct is null when the previous window was zero, because a relative change against zero is undefined.
  5. 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, or GET /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")%"'
    done

Python

"""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;
});

Next

On this page

VitalSentinel

Catch issues before they cost you

Track SEO, performance, and uptime in one place and get alerted the moment something breaks – hours before it hits your traffic.

  • Free plan for 1 domain
  • Set up in minutes
  • No credit card required