Webhooks

Events, signing secrets and verifying requests from Pingi.

Webhooks send monitor events to an HTTPS endpoint you run, so your own systems can react when a website goes down, recovers or has a certificate problem. Owners and admins manage them under Workspace → Webhooks, or through the API with the webhooks:write ability.

Webhook delivery is not switched on yet. You can register endpoints and prepare your receiver now; the dashboard shows when delivery is enabled.

Events

Event Sent when
monitor.down Downtime is confirmed and an incident opens
monitor.recovered The monitor is reachable again and the incident closes
ssl.invalid The certificate is invalid (chain, hostname or expired)
ssl.recovered A previously invalid certificate is valid again
ssl.expiring The certificate expires within the monitor's warning window

Signing secret

Each webhook has a signing secret (whsec_…). Pingi shows it once: in the dashboard right after you create the webhook, or in meta.secret of the API response. Store it with your endpoint.

If the secret is lost or exposed, create a new one (Create new secret in the dashboard, or POST /api/v1/webhooks/{id}/rotate-secret). The old secret stops working immediately.

Verifying a request

Every request carries two headers:

X-Pingi-Timestamp: <unix time the request was signed>
X-Pingi-Signature: sha256=<hex HMAC-SHA256 of "{timestamp}.{raw body}", keyed with your secret>

Compute the same value over the timestamp, a dot and the raw body (before parsing JSON), compare in constant time, and reject requests whose timestamp is more than a few minutes old — that stops a captured request from being replayed later:

$timestamp = $_SERVER['HTTP_X_PINGI_TIMESTAMP'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (! hash_equals($expected, $_SERVER['HTTP_X_PINGI_SIGNATURE'] ?? '') || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit;
}
const crypto = require('crypto');
const timestamp = req.headers['x-pingi-timestamp'] || '';
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(timestamp + '.' + rawBody).digest('hex'));
const received = Buffer.from(req.headers['x-pingi-signature'] || '');
const ok = received.length === expected.length && crypto.timingSafeEqual(received, expected)
  && Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;

Duplicates

A failed delivery is retried. All attempts for the same event share the X-Pingi-Delivery id, so your receiver can ignore ids it has already handled.