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:write—POST /v1/audits.snapshots:read—GET /v1/audits/{id}+ reports.metrics:read— aggregated Tinybird reads under/v1/metrics/*.
Quickstart
- Mint an API key from the dashboard. Store the secret on mint — it's only shown 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 anaudit.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: create a named device profile in the dashboard,
then pass
device_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.
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.
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 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 →