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 (a specific set needs at least one monitor).
  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": "HTTP 503 (service unavailable)",
"detail": {
"http_status": 503,
"latency_ms": 5231,
"sources_down": ["Singapore", "Frankfurt"],
"sources_up": ["office-lan-probe"],
"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, and carry "test": true at the top level. Real alerts never have a test key, so a receiver can ignore tests with if payload.get("test"). A test is shaped like a monitor.down for a made-up monitor (“Example API”); nothing is actually down.
  • event_at and sent_at are unix epoch seconds (not milliseconds, not ISO strings).
  • since is when this state began. For the first alert of an incident, that is the first failed check of the run of failures that led to it (see When an incident opens), so it is earlier than event_at, which is when the alert was sent after the outage was confirmed. With Alert me after set to 1 the two are the same moment.
  • Any field with no value is omitted — it is never sent as null. Treat every field inside monitor and detail as optional.
  • detail.latency_ms is the target’s real response time. It is omitted when nothing answered (a timeout, a refused connection, a silent TCP/UDP port or a ping with no reply), because the time the probe spent waiting is not a response time. It is still present when the target answered with an error, such as HTTP 503. Don’t assume it exists on a monitor.down event.
  • reason is a short human-readable cause; url links to the monitor (or the specific incident) in the CuliPulse console. It is free text, so show it but don’t match on it. For monitor.unknown it names the agent that stopped checking in when we know which one, for example Agent office hasn't checked in since 14:32 UTC — check that it's running, and otherwise reads All checkpoints unreachable. The fields of the payload are the same either way.

A recovery uses event: "monitor.up" and state: "up", and may carry detail.outage_seconds / detail.recovered_symptom instead of the down-only fields. reason is "Back up", except when the monitor was only degraded (for example a heartbeat whose run took too long) and never went down: then it is "Back to normal", so a slow run isn’t announced as an outage. The event is still monitor.up. Three optional detail fields tell you how the closed incident split:

Field Meaning
detail.outage_seconds How long the whole incident lasted, from when it opened to recovery (down and degraded time together). It counts from the first failed check, not from when the alert was sent
detail.down_seconds The part of it spent down (0 for a degraded-only incident)
detail.degraded_seconds The part of it spent degraded

down_seconds and degraded_seconds are omitted for incidents that were opened before CuliPulse tracked them, so treat them as optional like every other detail field.

A heartbeat that didn’t check in on time sends monitor.down with monitor.type: "heartbeat" and these detail fields:

Field Meaning
detail.schedule_seconds The interval you set
detail.last_seen_seconds How long before the alert the last check-in arrived
detail.deadline_at When the check-in was due, as unix epoch seconds: the last check-in (or the last schedule change, if later) plus the interval and the grace period
detail.overdue_seconds How late the check-in was when the alert fired. This is about 0, because the alert fires at the deadline, so use deadline_at for “when was it due”

detail.deadline_at is present on alerts sent from October 2026; treat it as optional.

event When
monitor.down / monitor.degraded A monitor started failing
monitor.unknown CuliPulse can’t check a monitor, for example because its agent stopped checking in. It is not a confirmed outage
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 and are dropped, not retried — point the destination straight at your endpoint.
  • 4xx (your endpoint rejected it) is treated as permanent: that delivery is dropped, not retried. The exceptions are 408 Request Timeout and 429 Too Many Requests, which are retried like a 5xx.
  • 5xx, 408, 429, timeouts, and network errors are retried: the initial send plus up to 3 retries, about 15, 30 and 60 seconds apart, each with a small random extra so a burst of alerts doesn’t retry all at once (two to three minutes in all). If your endpoint sends a Retry-After header (seconds or an HTTP date), CuliPulse waits at least that long before the next try, up to 5 minutes. After the last try, CuliPulse stops trying that alert for this destination.
  • Each destination is delivered on its own. A failing or slow destination never delays, blocks, or repeats the alert to your other destinations (Telegram, Slack, email, other webhooks).
  • Each retry is a new delivery with a new delivery_id and sent_at: delivery_id is never reused across retries. If you need to deduplicate, key on something stable across retries: event + monitor.id + event_at (agent.id instead of monitor.id for agent.* events), not delivery_id.
  • Order events by event_at, not by arrival. A retried alert can arrive after a newer one. Keep the latest event_at you’ve processed per monitor (or agent) and ignore anything older. CuliPulse also drops a retry that has gone stale, such as a monitor.down still being retried after the monitor recovered, but don’t rely on that alone.
  • Don’t deduplicate on detail.incident_id. The down, degraded and recovery (monitor.up) events of one incident all carry the same incident_id, so keying on it alone drops the recovery. Use it to group the events of one incident instead. It is also missing on some events, such as monitor.unknown.
  • 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. An account can have up to 20 notification destinations in total (webhooks, Telegram, Slack and email together); adding one past that is refused until you remove another. To keep your list tidy, CuliPulse won’t add a second webhook destination for exactly the same URL; edit the existing one instead. (A URL that differs in any way, such as its query string, counts as a different URL.) A destination you started but never finished connecting, such as a Telegram or Slack connection, counts toward the 20 until you delete it.

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