Authentication
Two credential types. Pick based on who's calling.
API keys
Minted per org from
POST /v1/management/orgs/{uuid}/api-keys. Sent as
Authorization: Bearer wv_live_…. Plaintext secret returned once at
creation; subsequent reads return prefix + metadata only.
curl -X POST https://api.webvitals.sh/v1/management/orgs/$ORG_UUID/api-keys \
-H "Authorization: Bearer $CLERK_JWT" \
-H "Content-Type: application/json" \
-d '{ "name": "ci", "scopes": ["snapshots:write", "snapshots:read", "metrics:read"] }'
Require api_access: true on the org's plan. Revoke with
DELETE /v1/management/orgs/{uuid}/api-keys/{key_uuid}.
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:write—POST /v1/audits.snapshots:read—GET /v1/audits/{id}+ reports.metrics:read— aggregated Tinybird reads under/v1/metrics/*.-
management— full CRUD under/v1/management/*. Power-user scope; not needed for the ergonomic audit surface. admin— superset of all of the above; includes destructive actions.
Quickstart
- Mint an API key. Store the
secretfield from the response — it's only returned once. - Fire an audit. One POST with a URL. No Site / Page / Device Profile setup first — the ergonomic API auto-creates records per origin.
- Poll or subscribe.
GET /v1/audits/{id}or wait for aaudit.completedwebhook. - 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: pre-create a named profile via
POST /v1/management/device-profilesand passdevice_profile_idon 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. Power-user endpoints under
/v1/management/* still surface the internal ss-… uuid; both are
accepted wherever an audit id is expected.
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_monthreturns402 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 withdevice_profile_label=mobile/4g®ion=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.
Management
/v1/management/* is the power-user CRUD surface. Needed if you want to
pre-create sites / pages / named device profiles, manage API keys + team members, or
schedule monitors beyond the ergonomic audit endpoint.
Namespace
/v1/management/sites,/v1/management/pages/v1/management/device-profiles,/v1/management/regions/v1/management/orgs,/v1/management/orgs/{uuid}/api-keys-
/v1/management/snapshots— the legacy batch-create shape (used by the scheduler under the hood; kept for first-class branded apps built on top of the service).
Scope
Every endpoint here requires the management or admin scope on
the API key, or an org-admin Clerk JWT. Callers with only
snapshots:write / metrics:read are 403 FORBIDDEN.
Webhooks
Register an endpoint per org via
POST /v1/management/orgs/{uuid}/webhooks. 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 inerror.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 mixeddevice_profile_idwithdevice/network. 429 RATE_LIMITED—Retry-Afterheader present.
Need the full schema? Live reference →