# API Quick Start



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 [#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 **Settings** → **API 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.

<Callout type="warning">
  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.
</Callout>

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 [#2-authenticate]

Send the secret as a bearer token:

```bash
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` [#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.

```bash
curl -sS https://app.vitalsentinel.com/api/public/v1/me \
  -H "Authorization: Bearer $VITALSENTINEL_API_KEY"
```

```json
{
  "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" }
}
```

<Callout type="warning">
  Build your client's capability list from `effective_scopes`, never from `scopes`. See [Scopes](/api/scopes#a-credential-is-a-ceiling-not-a-grant) for why the two can differ.
</Callout>

## 4. Find a domain [#4-find-a-domain]

```bash
curl -sS https://app.vitalsentinel.com/api/public/v1/domains \
  -H "Authorization: Bearer $VITALSENTINEL_API_KEY"
```

```json
{
  "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.

<Callout type="warning">
  **`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.
</Callout>

`/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 [#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`.

```bash
curl -sS \
  "https://app.vitalsentinel.com/api/public/v1/domains/$DOMAIN_ID/overview" \
  -H "Authorization: Bearer $VITALSENTINEL_API_KEY"
```

```json
{
  "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 [#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 [#curl]

```bash
#!/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 [#python]

```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 [#typescript]

```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 [#next]

<Cards>
  <Card title="Conventions" href="/api/conventions">
    Pagination, time ranges, idempotency, and the rules that apply to every endpoint.
  </Card>

  <Card title="Errors" href="/api/errors">
    The full problem catalog and what to do about each one.
  </Card>
</Cards>
