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.
- 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": "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_idis a UUID. Test deliveries (the one sent when you create the webhook, and Send test) use awhd_…id instead.event_atandsent_atare unix epoch seconds (not milliseconds, not ISO strings).- Any field with no value is omitted — it is never sent as
null. Treat every field insidemonitoranddetailas optional. reasonis a short human-readable cause;urllinks 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 types
Section titled “Event types”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.
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 — 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_idandsent_at. If you need to deduplicate, key on something stable across retries —event+monitor.id+event_at, ordetail.incident_id— notdelivery_id.
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, with no cap on the number of webhook destinations.
Prefer to pull data yourself instead of receiving pushes? See the API.