Alert webhooks
A webhook channel POSTs one JSON document per alert to the channel’s https URL. This is the
contract receivers build against; a change a receiver could trip on bumps version.
Request
POST <your URL>
Content-Type: application/json
User-Agent: Pingwell-Alerts/1
X-Pingwell-Delivery: 5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d
X-Pingwell-Timestamp: 1792962000
X-Pingwell-Signature: sha256=<hex>
{
"id": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
"version": 1,
"event": "down",
"check": { "type": "monitor", "id": "0b6f1a52-…", "name": "Public API" },
"status": "down",
"reason": "failing in us-east-1 (timeout), sa-east-1 (timeout)",
"at": "2026-10-25T12:00:00.000Z",
"url": "https://app.pingwell.dev/monitors/0b6f1a52-…"
}
| Field | Meaning |
|---|---|
id |
The delivery. The same on every retry of it, and in X-Pingwell-Delivery: drop one you have already handled. |
version |
1. |
event |
down, degraded (monitors), late (heartbeats), recovered, or test. |
check |
The monitor or heartbeat; null on a test. |
channel |
Only on a test: { "id", "name" } of the channel that sent it. |
status |
The check’s status after the transition: down, degraded, late, up, or test. |
reason |
Failing regions and probe reasons, or the heartbeat’s reason; may be null. |
at |
When the transition happened, ISO 8601 UTC with milliseconds. |
url |
The check in the control panel. |
degraded and late are sent only if still true two minutes later. recovered is sent only to
a channel that heard of the problem it ends. Nothing repeats while a check stays down.
Verifying the signature
The signature is HMAC-SHA256, keyed with the channel’s signing secret (whsec_…, shown once
when the channel is created), over the timestamp header, a dot, and the raw request body:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, headers, rawBody) {
const timestamp = headers["x-pingwell-timestamp"];
const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = Buffer.from(headers["x-pingwell-signature"] ?? "");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return fresh && given.length === expected.length && timingSafeEqual(given, Buffer.from(expected));
}
X-Pingwell-Timestamp is unix seconds at sending time (each retry is signed afresh); refusing
old timestamps stops a captured delivery from being replayed.
Responses and retries
Any 2xx is delivered. A 5xx, a 429, a timeout (10 s) or a connection failure is retried after 30 s, 2 min, 10 min and 30 min; the fifth failure, or any other 4xx at once, is dead and shows on the channel. Redirects are not followed. The URL must be https and resolve only to public addresses.