Outgoing webhooks (v1)
PulseWatch POSTs a versioned JSON payload to your webhook URL whenever a monitor changes state. Slack (hooks.slack.com) and Discord (discord.com/api/webhooks) URLs automatically receive native rich messages instead: set the channel format to raw to force v1 JSON everywhere.
Payload
{
"version": "1",
"event": "monitor.down",
"sent_at": "2026-07-21T14:03:07.512Z",
"monitor": {
"id": "b0000000-0000-4000-a000-000000000003",
"name": "Checkout API",
"type": "uptime",
"url": "https://api.example.com/healthz",
"state": "down",
"previous_state": "up"
},
"details": {
"reason": "http_error",
"last_ping_at": null,
"expected_by": null,
"status_code": 503,
"latency_ms": 1240,
"error": "unexpected status 503",
"metric": null,
"metric_value": null,
"bound": null,
"error_group": null,
"severity": "major",
"alert_type": "error",
"reason_meaning": "The URL answered with a status outside the expected range.",
"runbook_url": "https://wiki.example.com/runbooks/checkout",
"runbook_excerpt": null
},
"dashboard_url": "https://pulsewatcher.vercel.app/app/monitors/b0000000-0000-4000-a000-000000000003",
"ack_url": "https://pulsewatcher.vercel.app/act/ack?token=<64 hex characters>",
"snooze_url": "https://pulsewatcher.vercel.app/act/snooze?token=<64 hex characters>"
}Field reference
| Field | Type | Meaning |
|---|---|---|
| version | string | Payload version. Only changes on a breaking format change; additive fields do not bump it. |
| event | string | One of monitor.down · monitor.up · monitor.ssl_expiring · monitor.error_new · monitor.error_persisting · monitor.warn · monitor.persistent_warnings · monitor.warn_digest · fleet.breached · fleet.recovered · monitor.completion_budget. Treat it as open: new events are added additively. |
| sent_at | string (ISO) | When PulseWatch sent this delivery. |
| monitor.id | string (uuid) | The monitor. |
| monitor.name | string | Monitor name (as shown in your dashboard). |
| monitor.type | string | heartbeat or uptime. |
| monitor.url | string | null | The checked URL for uptime monitors; null for heartbeats. |
| monitor.state | string | New state: new · up · late · warn · blocked · down · paused. |
| monitor.previous_state | string | State immediately before this event. |
| details.reason | string | Why the alert fired: see the reason table below. |
| details.last_ping_at | string | null | Last heartbeat received (heartbeat monitors). |
| details.expected_by | string | null | When the next ping was expected (heartbeat monitors). |
| details.status_code | number | null | HTTP status from the failing check (uptime monitors). |
| details.latency_ms | number | null | Response latency of the failing check, if measured. |
| details.error | string | null | Human-readable error (e.g. "unexpected status 503"). |
| details.metric | string | null | Metric name for a metric-rule violation, else null. |
| details.metric_value | number | null | The offending metric value, else null. |
| details.bound | string | null | The rule bound that was crossed (e.g. "min 1000"), else null. |
| details.error_group | object | null | Parsed stack trace + grouping when the failing ping carried one; null otherwise. See below. |
| details.runbook_url | string | null | The monitor's runbook link, if one is set. |
| details.runbook_excerpt | string | null | The runbook itself, rendered and trimmed to 800 characters. Null when the monitor and the account both have none. |
| dashboard_url | string | Deep link to the monitor in your dashboard. |
| ack_url | string | null | Single-use link that acknowledges this incident: POST it with {"token": "…"}. Null on recoveries and digests. Treat as a secret: it acts without a session. |
| snooze_url | string | null | Single-use link that snoozes this monitor for an hour. Same rules. |
Stack traces: details.error_group
When a /fail ping carries a parseable stack trace, the alert includes a grouped summary. This field is additive: it was introduced after v1 shipped, so the version field is still "1" and no existing field was renamed or removed. Note the key is error_group, not error: details.error remains the human-readable string it has always been.
{
"error_group": {
"fingerprint": "9c1f0a7d3b5e28a4c6d0f2b8e4a1c7d95f3b60e2a8c4d1f7b3e9a05c2d6f8b41",
"language": "python",
"exception_type": "KeyError",
"message": "'user_id'",
"file": "/app/run.py",
"line": 42,
"function": "main",
"is_new": true,
"is_regression": false,
"occurrences": 1,
"first_seen_at": "2026-07-22T03:00:12.412Z"
}
}| Field | Type | Meaning |
|---|---|---|
| fingerprint | string | Stable id for this failure shape (sha256). Same bug on a shifted line keeps the same fingerprint. |
| language | string | python · node · java · go · ruby · php · dotnet · shell · unknown. |
| exception_type | string | null | e.g. KeyError, java.sql.SQLException, "runtime error". Null for unparsed output. |
| message | string | null | First-line message, scrubbed and truncated. |
| file | string | null | File of the deepest non-vendor frame. |
| line | number | null | Line of that frame. Not part of the fingerprint. |
| function | string | null | Function/method of that frame. |
| is_new | boolean | This fingerprint has not been seen on this monitor in the last 30 days. |
| is_regression | boolean | The group existed, went quiet or was resolved, and has returned. |
| occurrences | number | Total occurrences recorded for this group. |
| first_seen_at | string (ISO) | When this group was first recorded. |
Events & reasons
Both lists are generated from the types the server dispatches on, so a new event cannot ship without appearing here. Full descriptions and the severity of every reason are on the states and reasons reference.
| event | Sent when |
|---|---|
| monitor.down | The monitor entered down. Carries the reason and, on a heartbeat, when the ping was expected. |
| monitor.up | The monitor recovered. `details.reason` is `recovered`. |
| monitor.ssl_expiring | The certificate on an uptime monitor is inside the expiry warning window. |
| monitor.error_new | A stack-trace fingerprint never seen on this monitor appeared. Can fire while already down. |
| monitor.error_persisting | The same fingerprint failed on N consecutive runs, where N is `escalate_after_consecutive`. |
| monitor.warn | A run completed with problems. The monitor is still available and no incident opens. |
| monitor.persistent_warnings | Consecutive warns crossed `warn_escalate_after`. This one does open an incident. |
| monitor.warn_digest | The batched roll-up of warnings for one monitor, sent on the digest schedule rather than per run. |
| fleet.breached | A fleet crossed its alert threshold. One delivery for the whole fleet. Branch on `details.reason` to tell missing instances from a rule breach or a blown completion budget. |
| fleet.recovered | A fleet dropped back below its threshold. Names the instances that returned. |
| monitor.completion_budget | A monitor ran fewer times over its budget window than the budget allows. Never sent alongside a down alert for the same period. |
details.reason values: no_pingfail_pinghttp_errortimeoutconnection_errorkeyword_mismatchrecoveredssl_expiringmetric_missingmetric_below_minmetric_above_maxmetric_below_baselineexit_codestale_upstreamoverlapping_runsduration_regressionoutput_changedpersistent_warningsrun_pileupcompletion_budgetexpected_shortfallexpected_surplusexpected_mismatchexpected_missing_actualexpected_missingexpected_zero_suspiciousexpected_value_anomalous
Headers
| Content-Type | application/json |
| X-PulseWatch-Event | monitor.down · monitor.up · monitor.ssl_expiring · monitor.error_new · monitor.error_persisting · monitor.warn · monitor.persistent_warnings · monitor.warn_digest · fleet.breached · fleet.recovered · monitor.completion_budget |
| X-PulseWatch-Delivery | unique UUID per delivery attempt (retries get a new one) |
| X-PulseWatch-Signature | sha256=<hex HMAC-SHA256 of the raw body, keyed with your signing secret> |
Verifying signatures
Every channel has a signing secret (shown once at creation: rotate it anytime from Channels). Compute HMAC-SHA256 over the raw request body and compare in constant time. These snippets are checked against the server's real signer by a unit test.
import { createHmac, timingSafeEqual } from 'node:crypto'
// The signature covers the RAW body: capture it before JSON parsing.
export function verifyPulseWatchSignature(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(signatureHeader ?? '')
return a.length === b.length && timingSafeEqual(a, b)
}
// Express: app.post('/hooks/pulsewatch',
// express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyPulseWatchSignature(req.body, req.get('x-pulsewatch-signature'), SECRET))
// return res.status(401).end()
// res.status(200).end()
// })Delivery & retries
- Timeout: 5 seconds per attempt. Any 2xx counts as delivered.
- One retry ~30 seconds after a network error or 5xx response (4xx is not retried).
- Each attempt has its own
X-PulseWatch-Deliveryid and is logged to your alert history. - The "Test" button on a channel sends a realistic signed
monitor.downsample. - Secret rotation: generate a new signing secret in Channels; deliveries switch immediately, so roll it into your verifier first.