Docs
Webhooks
A webhook channel sends every alert as a JSON POST request to your endpoint. Every request is signed, so you can check that it really comes from us.
Setting one up
- Go to Alerts → Add channel → Webhook and paste your endpoint. It must be
httpsand publicly reachable. We do not send to private or local addresses, and we do not follow redirects. - Your signing secret (
whsec_…) is shown right away. Store it on your side. - We send a test request. As soon as your endpoint answers with any
2xx, the channel is confirmed and starts receiving alerts.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-PulseKeeper-Timestamp: 1790479747
X-PulseKeeper-Signature: 5d41402abc4b2a76b9719d911017c592…
X-PulseKeeper-Delivery: ntf_1842
| Header | Meaning |
|---|---|
X-PulseKeeper-Timestamp |
Unix time when we signed the request. |
X-PulseKeeper-Signature |
Hex HMAC-SHA256 of timestamp.body with your secret. During a secret rotation it carries two signatures, comma-separated. |
X-PulseKeeper-Delivery |
ID of this message. It stays the same on every retry, so you can ignore duplicates. |
Verifying the signature
Compute the HMAC over the timestamp, a dot and the raw request body, and compare it with each signature in the header. Reject requests older than 5 minutes.
PHP
$timestamp = $request->header('X-PulseKeeper-Timestamp');
$expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $secret);
$valid = abs(time() - (int) $timestamp) < 300
&& collect(explode(',', $request->header('X-PulseKeeper-Signature')))
->contains(fn ($signature) => hash_equals($expected, trim($signature)));
JavaScript
import crypto from 'node:crypto';
const timestamp = req.headers['X-PulseKeeper-Timestamp'.toLowerCase()];
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const valid = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300
&& req.headers['X-PulseKeeper-Signature'.toLowerCase()].split(',')
.some((s) => crypto.timingSafeEqual(Buffer.from(s.trim()), Buffer.from(expected)));
Use the raw body exactly as received: re-encoding the JSON changes it and the signature will not match. Use a constant-time comparison (hash_equals, timingSafeEqual).
Rotating the secret
Rotate secret creates a new one. For the next 24 hours we sign every request with both the new and the old secret, so you can deploy the new one without missing an alert.
Events
The event field tells you what happened, so your endpoint does not need to keep state:
| Event | When |
|---|---|
incident.alert |
An incident is confirmed (first alert, and later escalation steps). |
incident.reminder |
The incident is still open. Reminders slow down, see thresholds and reminders. |
incident.recovery |
The incident is resolved. Sent to every channel that got the alert. |
monitor.never_connected |
A new target has never answered, usually because of a typo or a firewall. |
deployment.regressed |
A deploy was noticeably slower or heavier than the hour before it. |
notifications.batch |
Several events grouped into one message (quiet hours, or many alerts at once). |
channel.test |
You clicked Send test. |
An incident event looks like this:
{
"event": "incident.alert",
"incident": {
"id": 128,
"type": "downtime",
"severity": "critical",
"state": "confirmed",
"started_at": "2026-09-27T12:24:02+00:00",
"confirmed_at": "2026-09-27T12:25:02+00:00",
"resolved_at": null,
"duration_seconds": null,
"error_code": "http_5xx",
"affected_locations": ["fra", "nyc"],
"healthy_locations": [],
"url": "http://pulsekeeper.io/domain/3f9a1c07e4b2d815?tab=incidents"
},
"monitor": {
"id": 42,
"type": "http",
"name": "shop.example.com",
"hostname": "shop.example.com",
"url": "https://shop.example.com"
}
}
Times are ISO 8601 in UTC. severity is critical for outages and warning for things that need attention but are not an outage: an expiring certificate, a firewall blocking our checks or a slower deploy.
Retries
If your endpoint does not answer within 8 seconds or returns anything other than 2xx, we try again after 10 seconds and after another minute, three attempts in total, all with the same X-PulseKeeper-Delivery. The last error is shown on the channel on the Alerts page.