Skip to content
Open app

Webhooks

A webhook destination sends every alert to a URL you control as an HTTP POST, so you can wire CuliPulse into your own tools — a custom bot, an incident-management system, a Slack app of your own, or an internal dashboard. Each request is signed, so you can confirm it really came from CuliPulse before acting on it.

  1. Go to Notifications → Add destination → Webhook.
  2. Enter the destination URL. It must be HTTPS and reachable from the public internet. Private, loopback, and unroutable addresses are rejected (see Security).
  3. Choose whether it fires for all monitors or a specific set.
  4. CuliPulse sends one test delivery to the URL. The destination only goes live if that test gets a 2xx response. If it fails, the error tells you why (see below).
  5. On success, CuliPulse shows your signing secret (it starts with whsec_) once. Copy it now and store it somewhere safe — it is never shown again, and there is no way to retrieve it later. If you lose it, create a new webhook destination.

The error names the reason, so you know what to fix:

  • your endpoint answered HTTP <status> — your endpoint returned a non-2xx status; check your server logs for that request.
  • your endpoint redirected (HTTP <status>); use the final URL — redirects aren’t followed; point the destination straight at the final URL instead.
  • your endpoint did not answer within 8 seconds — respond faster, or ack immediately and do the slow work afterward (see Responding, retries, and idempotency).
  • could not reach your endpoint — check the domain name — the domain in the URL doesn’t exist or has no DNS record. Look for a typo, or make sure the DNS record has been created.
  • could not connect to your endpoint — check that its server is running — the domain was found, but nothing accepted the connection. Check that your server is up and listening on the port in the URL. (Other connection problems show the shorter could not connect to your endpoint.)
  • could not make a secure connection to your endpoint — check its HTTPS certificate — the certificate is expired, self-signed, or doesn’t match the domain. Use a certificate from a public certificate authority.
  • the URL points to a private or unreachable address — use a public, internet-reachable address (see Security).

Every delivery is an HTTP POST with Content-Type: application/json and these headers:

Header Value
X-CuliPulse-Event The event name, e.g. monitor.down
X-CuliPulse-Delivery A unique id for this delivery attempt
X-CuliPulse-Timestamp When it was sent, as unix epoch seconds
X-CuliPulse-Signature sha256= followed by the hex HMAC (see Verify)

Every payload shares the same envelope, plus fields specific to the event:

{
"version": "1",
"event": "monitor.down",
"delivery_id": "3f2a9c4e-7b1d-4e8a-9c5f-…",
"event_at": 1699999900,
"sent_at": 1699999999,
"monitor": {
"id": "mon_demo",
"name": "Example API",
"type": "http",
"target": "https://api.example.com/health"
},
"state": "down",
"since": 1699999880,
"reason": "Connection timed out — connection timed out after 10s",
"detail": {
"http_status": 503,
"latency_ms": 5231,
"sources_down": ["Singapore", "Frankfurt"],
"sources_up": ["Tokyo"],
"incident_id": "inc_demo"
},
"url": "https://culipulse.dev/monitors/mon_demo#incident-inc_demo"
}
  • delivery_id is a UUID. Test deliveries (the one sent when you create the webhook, and Send test) use a whd_… id instead.
  • event_at and sent_at are unix epoch seconds (not milliseconds, not ISO strings).
  • Any field with no value is omitted — it is never sent as null. Treat every field inside monitor and detail as optional.
  • reason is a short human-readable cause; url links to the monitor (or the specific incident) in the CuliPulse console.

A recovery uses event: "monitor.up", state: "up", reason: "Back up", and may carry detail.outage_seconds / detail.recovered_symptom instead of the down-only fields.

event When
monitor.down / monitor.degraded / monitor.unknown A monitor started failing
monitor.up A monitor recovered
agent.offline / agent.online A private agent went offline or came back
advisory.domain_expiry A watched domain is expiring or has expired

agent.* events carry an agent object (id, name, region) instead of monitor; advisory.domain_expiry carries monitor, domain, and a state of expiring or expired.

Every request includes X-CuliPulse-Signature: sha256=<hex>. The value is:

HMAC-SHA256(signing_secret, raw_request_body) → lowercase hex, prefixed with "sha256="

To verify a request:

  1. Read the raw request body bytes — before any JSON parsing/re-serialization. The signature is over the exact bytes we sent, so a re-encoded body will not match.
  2. Compute HMAC-SHA256 of that raw body using your signing_secret as the key, hex-encoded.
  3. Compare sha256=<your hex> to the X-CuliPulse-Signature header using a constant-time comparison. If they match, the request is authentic.
import crypto from 'node:crypto';
function verifyCuliPulse(rawBody, signatureHeader, signingSecret) {
const expected =
'sha256=' + crypto.createHmac('sha256', signingSecret).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader ?? '');
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: capture the RAW body, not the parsed object.
// app.post('/culipulse', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyCuliPulse(req.body, req.get('X-CuliPulse-Signature'), process.env.CULIPULSE_SECRET))
// return res.sendStatus(401);
// const event = JSON.parse(req.body.toString('utf8'));
// res.sendStatus(200); // ack fast, then process
// });
import hmac, hashlib
def verify_culipulse(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
expected = "sha256=" + hmac.new(
signing_secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header or "")
# Flask: use request.get_data() for the raw bytes, not request.json.
  • Respond 2xx quickly. Acknowledge first, then do the slow work. The request is aborted after 8 seconds.
  • Any non-2xx response is treated as a failure. Redirects (3xx) are never followed and count as failures — point the destination straight at your endpoint.
  • 4xx (your endpoint rejected it) is treated as permanent: that delivery is dropped, not retried.
  • 5xx, timeouts, and network errors are retried (the initial send plus up to 3 retries, then the alert lands in a dead-letter queue).
  • Each retry is a new delivery with a new delivery_id and sent_at. If you need to deduplicate, key on something stable across retries — event + monitor.id + event_at, or detail.incident_id — not delivery_id.
  • The URL must be HTTPS and resolve to a public address. Private, loopback, link-local, CGNAT, and cloud-metadata ranges are blocked, both when you create the destination and again on every send.
  • Redirects are never followed (an endpoint that redirects could point anywhere).
  • Your signing secret and destination URL are encrypted at rest (AES-256-GCM); the full URL is never shown again after setup (only its host is displayed).

Webhooks are available on every plan, with no cap on the number of webhook destinations.

Prefer to pull data yourself instead of receiving pushes? See the API.