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

  1. Go to Alerts → Add channel → Webhook and paste your endpoint. It must be https and publicly reachable. We do not send to private or local addresses, and we do not follow redirects.
  2. Your signing secret (whsec_…) is shown right away. Store it on your side.
  3. 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.