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.