API

URL in, audit out. The primary endpoint is POST /v1/audits. For the full schema — parameters, response shapes, error codes — see the live reference, rendered from the deployed OpenAPI document.

Authentication

Two credential types. Pick based on who's calling.

API keys

Mint keys from the dashboard. Sent as Authorization: Bearer sk_live_…. Plaintext secret shown once at creation; subsequent reads surface prefix + metadata only. Revoke from the same settings page.

API keys require api_access: true on the org's plan.

Clerk JWTs

The hosted dashboard signs every request with a Clerk JWT. Used for endpoints an API key can't call — minting keys, managing members, updating billing.

Scopes

  • snapshots:writePOST /v1/audits.
  • snapshots:readGET /v1/audits/{id} + reports.
  • metrics:read — aggregated Tinybird reads under /v1/metrics/*.

Quickstart

  1. Mint an API key from the dashboard. Store the secret on mint — it's only shown once.
  2. Fire an audit. One POST with a URL. No Site / Page / Device Profile setup first — the ergonomic API auto-creates records per origin.
  3. Poll or subscribe. GET /v1/audits/{id} or wait for an audit.completed webhook.
  4. Read the full Lighthouse JSON via GET /v1/audits/{id}/report.

Audits

An audit is one Lighthouse run — a single (url, device, network, region) tuple at a point in time.

Single URL

curl -X POST https://api.webvitals.sh/v1/audits \
  -H "Authorization: Bearer $WEBVITALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/pricing",
    "device": "mobile",
    "network": "4g",
    "region": "europe-west2"
  }'

Defaults: device: "mobile", network: "4g" (mobile) or "cable" (desktop), region: "europe-west2".

Batch

Same endpoint, pass urls instead of url. Every URL runs with the same (device, network, region) tuple. Cap: 150 URLs per request.

curl -X POST https://api.webvitals.sh/v1/audits \
  -H "Authorization: Bearer $WEBVITALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": [
      "https://example.com/",
      "https://example.com/pricing",
      "https://example.com/docs"
    ],
    "device": "mobile",
    "network": "4g",
    "region": "europe-west2"
  }'

Devices and networks

  • device: mobile | desktop.
  • network: none | cable | 4g | slow-4g | 3g | slow-3g. Matches Lighthouse throttling presets.
  • Custom viewport / UA / throttling: create a named device profile in the dashboard, then pass device_profile_id on the audit request.

Response — pending

{
  "id": "aud_8f3k2pXq7m",
  "url": "https://example.com/pricing",
  "status": "pending",
  "device": "mobile",
  "network": "4g",
  "region": "europe-west2",
  "device_profile_label": "mobile/4g",
  "status_url": "https://api.webvitals.sh/v1/audits/aud_8f3k2pXq7m",
  "created_at": "2026-04-28T12:00:00Z"
}

Response — success

{
  "id": "aud_8f3k2pXq7m",
  "url": "https://example.com/pricing",
  "status": "success",
  "device": "mobile",
  "network": "4g",
  "region": "europe-west2",
  "device_profile_label": "mobile/4g",
  "started_at": "2026-04-28T12:00:02Z",
  "completed_at": "2026-04-28T12:00:18Z",
  "duration_ms": 16400,
  "scores": {
    "performance": 92,
    "accessibility": 100,
    "best_practices": 96,
    "seo": 100
  },
  "metrics": {
    "lcp_ms": 1823,
    "cls": 0.02,
    "inp_ms": 180,
    "tbt_ms": 140,
    "fcp_ms": 812,
    "si_ms": 2100,
    "ttfb_ms": 240
  },
  "report_url": "https://api.webvitals.sh/v1/audits/aud_8f3k2pXq7m/report"
}

scores.* are 0–100 integers. metrics.* are the flat CWVs every dev cares about — full LHR available via report_url.

id is formatted aud_ + 10-char nanoid.

Status lifecycle

pending → running → success
                 ↘ error
                 ↘ timeout

Stuck running rows are reclaimed by the grace-period cron after 10 minutes.

Reports

Once status is success, GET /v1/audits/{id}/report returns a presigned R2 URL to the gzipped Lighthouse JSON. Valid for 5 minutes; re-fetch if you need longer access.

{
  "url": "https://...r2.cloudflarestorage.com/...?X-Amz-Signature=...",
  "expires_in_seconds": 300,
  "r2_key": "reports/org-xxxxxxxxxx/ss-xxxxxxxxxx.json.gz"
}

Limits

  • Batch size: 150 URLs per request.
  • Concurrency: per-org max_concurrent_snapshots. Over-limit items wait in-queue.
  • Plan budget: a request that would exceed max_on_demand_per_month returns 402 PLAN_LIMIT_EXCEEDED.

Metrics

Every successful audit emits a row to Tinybird. Read endpoints aggregate those server-side so the client gets clean time-series instead of raw LHR JSON.

Endpoints

  • GET /v1/sites/{uuid}/metrics/latest — most recent success per (page, region, device_profile_label).
  • GET /v1/sites/{uuid}/metrics/trend?interval=day — bucketed avg + p75 per metric, grouped by (bucket, device_profile_label, region). Filter to a single series with device_profile_label=mobile/4g&region=europe-west2.
  • GET /v1/sites/{uuid}/metrics/p75 — p75 CWVs per page across the window.
  • GET /v1/pages/{uuid}/metrics/p75 — same pipe narrowed to a single page.
  • GET /v1/sites/{uuid}/overview — run counts + score averages across the window.

Series keying

Every metrics response carries device_profile_label — a stable human-readable string (mobile/4g for stock presets, your custom name for named profiles). Charts keyed by label group correctly across profile renames and supersession boundaries.

Retention

Follows the plan's retention_months. Reads requesting older than retention silently truncate. Ingest lag is typically <30s.


Webhooks

Register an endpoint per org from the dashboard. You'll get a signing secret back — store it; you'll use it to verify deliveries.

Delivery

POST to your URL with Content-Type: application/json. Headers include X-Webvitals-Signature (HMAC-SHA256 over the body) and X-Webvitals-Timestamp (unix seconds). Retries: 5 attempts with exponential backoff (1s, 5s, 25s, 2m, 10m). Unhandled deliveries land in your org's dead-letter log.

Payload

{
  "event": "audit.completed",
  "delivered_at": "2026-04-28T12:00:22Z",
  "data": {
    "id": "aud_8f3k2pXq7m",
    "url": "https://example.com/pricing",
    "status": "success",
    "device": "mobile",
    "network": "4g",
    "region": "europe-west2",
    "device_profile_label": "mobile/4g",
    "duration_ms": 16400,
    "scores": { "performance": 92, "accessibility": 100, "best_practices": 96, "seo": 100 },
    "metrics": { "lcp_ms": 1823, "cls": 0.02, "inp_ms": 180, "tbt_ms": 140, "fcp_ms": 812, "si_ms": 2100, "ttfb_ms": 240 },
    "report_url": "https://api.webvitals.sh/v1/audits/aud_8f3k2pXq7m/report"
  }
}

Verification

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, timestamp, body, signature) {
  const ageSec = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (Number.isNaN(ageSec) || ageSec > 300) return false;

  const expected = createHmac('sha256', secret)
    .update(timestamp + '.' + body)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Events

  • audit.completed — fires on any terminal status (success, error, timeout).

Errors

All errors share the shape { error: { code, message, details?, meta? } }. Common codes:

  • 400 INVALID_URL — URL fails parse or has a non-http/https scheme.
  • 400 URL_NOT_ALLOWED — URL points at a private / internal host.
  • 401 UNAUTHENTICATED — bearer missing, malformed, or unknown.
  • 403 FORBIDDEN — credential valid, scope or role missing.
  • 402 PLAN_LIMIT_EXCEEDED — plan entitlement blocks the action. Counter state in error.meta.
  • 404 NOT_FOUND — resource missing or outside your tenant.
  • 404 REGION_NOT_FOUND / DEVICE_PROFILE_NOT_FOUND — preset / profile id not recognised.
  • 409 IDEMPOTENCY_CONFLICT — same key, different payload. (Future — not enforced yet.)
  • 422 VALIDATION_FAILED / DEVICE_NETWORK_CONFLICT — request body failed schema validation or mixed device_profile_id with device/network.
  • 429 RATE_LIMITEDRetry-After header present.

Need the full schema? Live reference →