Webhooks
Last updated
Long tests are a poor fit for polling. Pass a callback URL when you start one and RankVitals POSTs the result the moment it finishes.
Requesting a callback
Add callback_url (a public https URL) to the start-a-test request. It is per-test, so different jobs can route to different endpoints without any account-level configuration.
curl -X POST https://rankvitals.io/api/v1/tests \
-H "Authorization: Bearer rv_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com",
"device": "mobile",
"callback_url": "https://your-app.example.com/hooks/rankvitals"}'Payload
The hook fires for both outcomes, so branch on test.status rather than assuming success.
{ "event": "test.completed",
"test": { "id": "…", "url": "…", "type": "lighthouse", "status": "completed",
"performance_score": 92, "structure_score": 88, "grade": "A",
"completed_at": "2026-07-19T12:00:00Z" },
"report_url": "https://rankvitals.io/report/…" }Delivery and retries
One POST with a 10-second timeout, then a single retry 30 seconds later after a network error or 5xx response. A 4xx response is permanent and is not retried. Treat the endpoint as at-least-once and key your handler on test.id.
Verifying signatures
Generate a team webhook secret under Dashboard → API & MCP. Signed deliveries include X-RankVitals-Signature: t=UNIX_SECONDS,v1=HEX_DIGEST. Compute HMAC-SHA256 over t + '.' + rawBody, compare the digest in constant time, and reject timestamps more than five minutes old. Always verify the exact raw request bytes before parsing JSON. Teams that have not generated a secret receive no signature header.
// Node.js (Express)
import crypto from 'node:crypto';
app.post('/hooks/rankvitals', express.raw({ type: 'application/json' }), (req, res) => {
const parts = Object.fromEntries(
String(req.headers['x-rankvitals-signature']).split(',').map((p) => p.split('=', 2))
);
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
const expected = crypto.createHmac('sha256', process.env.RANKVITALS_WEBHOOK_SECRET)
.update(parts.t + '.').update(req.body).digest('hex');
const valid = /^[0-9a-f]{64}$/.test(parts.v1 ?? '') && age <= 300 &&
crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
if (!valid) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(204);
});# Python (Flask)
import hashlib, hmac, os, time
from flask import request, abort
@app.post('/hooks/rankvitals')
def rankvitals_hook():
raw = request.get_data()
parts = dict(part.split('=', 1) for part in
request.headers.get('X-RankVitals-Signature', '').split(',') if '=' in part)
try:
timestamp = int(parts.get('t', '0'))
except ValueError:
abort(400)
expected = hmac.new(os.environ['RANKVITALS_WEBHOOK_SECRET'].encode(),
str(timestamp).encode() + b'.' + raw, hashlib.sha256).hexdigest()
if abs(int(time.time()) - timestamp) > 300 or not hmac.compare_digest(parts.get('v1', ''), expected):
abort(400)
event = request.get_json()
return '', 204