Skip to main content

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