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.
Set up a webhook
Section titled “Set up a webhook”- Go to Notifications → Add destination → Webhook.
- Enter the destination URL. It must be HTTPS and reachable from the public internet. Private, loopback, and unroutable addresses are rejected (see Security).
- Choose whether it fires for all monitors or a specific set (a specific set needs at least one monitor).
- CuliPulse sends one test delivery to the URL. The destination only goes live if that
test gets a
2xxresponse. If it fails, the error tells you why (see below). - 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.
If the test fails
Section titled “If the test fails”The error names the reason, so you know what to fix:
your endpoint answered HTTP <status>— your endpoint returned a non-2xxstatus; 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 shortercould 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).
The request CuliPulse sends
Section titled “The request CuliPulse sends”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) |
Payload
Section titled “Payload”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_idis a UUID. Test deliveries (the one sent when you create the webhook, and Send test) use awhd_…id instead, and carry"test": trueat the top level. Real alerts never have atestkey, so a receiver can ignore tests withif payload.get("test"). A test is shaped like amonitor.downfor a made-up monitor (“Example API”); nothing is actually down.event_atandsent_atare unix epoch seconds (not milliseconds, not ISO strings).sinceis 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 thanevent_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 insidemonitoranddetailas optional. detail.latency_msis 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 amonitor.downevent.reasonis a short human-readable cause;urllinks to the monitor (or the specific incident) in the CuliPulse console. It is free text, so show it but don’t match on it. Formonitor.unknownit names the agent that stopped checking in when we know which one, for exampleAgent office hasn't checked in since 14:32 UTC — check that it's running, and otherwise readsAll 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.
Heartbeat alerts
Section titled “Heartbeat alerts”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 types
Section titled “Event types”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.
Verify the signature
Section titled “Verify the signature”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:
- 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.
- Compute
HMAC-SHA256of that raw body using yoursigning_secretas the key, hex-encoded. - Compare
sha256=<your hex>to theX-CuliPulse-Signatureheader using a constant-time comparison. If they match, the request is authentic.
Node.js
Section titled “Node.js”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// });Python
Section titled “Python”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.Responding, retries, and idempotency
Section titled “Responding, retries, and idempotency”- Respond
2xxquickly. Acknowledge first, then do the slow work. The request is aborted after 8 seconds. - Any non-
2xxresponse 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 are408 Request Timeoutand429 Too Many Requests, which are retried like a5xx.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 aRetry-Afterheader (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_idandsent_at:delivery_idis never reused across retries. If you need to deduplicate, key on something stable across retries:event+monitor.id+event_at(agent.idinstead ofmonitor.idforagent.*events), notdelivery_id. - Order events by
event_at, not by arrival. A retried alert can arrive after a newer one. Keep the latestevent_atyou’ve processed per monitor (or agent) and ignore anything older. CuliPulse also drops a retry that has gone stale, such as amonitor.downstill 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 sameincident_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 asmonitor.unknown.
Security
Section titled “Security”- 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).
Limits
Section titled “Limits”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.