PulseWatchPulsewatch

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

json
{
  "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

FieldTypeMeaning
versionstringPayload version. Only changes on a breaking format change; additive fields do not bump it.
eventstringOne 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_atstring (ISO)When PulseWatch sent this delivery.
monitor.idstring (uuid)The monitor.
monitor.namestringMonitor name (as shown in your dashboard).
monitor.typestringheartbeat or uptime.
monitor.urlstring | nullThe checked URL for uptime monitors; null for heartbeats.
monitor.statestringNew state: new · up · late · warn · blocked · down · paused.
monitor.previous_statestringState immediately before this event.
details.reasonstringWhy the alert fired: see the reason table below.
details.last_ping_atstring | nullLast heartbeat received (heartbeat monitors).
details.expected_bystring | nullWhen the next ping was expected (heartbeat monitors).
details.status_codenumber | nullHTTP status from the failing check (uptime monitors).
details.latency_msnumber | nullResponse latency of the failing check, if measured.
details.errorstring | nullHuman-readable error (e.g. "unexpected status 503").
details.metricstring | nullMetric name for a metric-rule violation, else null.
details.metric_valuenumber | nullThe offending metric value, else null.
details.boundstring | nullThe rule bound that was crossed (e.g. "min 1000"), else null.
details.error_groupobject | nullParsed stack trace + grouping when the failing ping carried one; null otherwise. See below.
details.runbook_urlstring | nullThe monitor's runbook link, if one is set.
details.runbook_excerptstring | nullThe runbook itself, rendered and trimmed to 800 characters. Null when the monitor and the account both have none.
dashboard_urlstringDeep link to the monitor in your dashboard.
ack_urlstring | nullSingle-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_urlstring | nullSingle-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.

json
{
  "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"
  }
}
FieldTypeMeaning
fingerprintstringStable id for this failure shape (sha256). Same bug on a shifted line keeps the same fingerprint.
languagestringpython · node · java · go · ruby · php · dotnet · shell · unknown.
exception_typestring | nulle.g. KeyError, java.sql.SQLException, "runtime error". Null for unparsed output.
messagestring | nullFirst-line message, scrubbed and truncated.
filestring | nullFile of the deepest non-vendor frame.
linenumber | nullLine of that frame. Not part of the fingerprint.
functionstring | nullFunction/method of that frame.
is_newbooleanThis fingerprint has not been seen on this monitor in the last 30 days.
is_regressionbooleanThe group existed, went quiet or was resolved, and has returned.
occurrencesnumberTotal occurrences recorded for this group.
first_seen_atstring (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.

eventSent when
monitor.downThe monitor entered down. Carries the reason and, on a heartbeat, when the ping was expected.
monitor.upThe monitor recovered. `details.reason` is `recovered`.
monitor.ssl_expiringThe certificate on an uptime monitor is inside the expiry warning window.
monitor.error_newA stack-trace fingerprint never seen on this monitor appeared. Can fire while already down.
monitor.error_persistingThe same fingerprint failed on N consecutive runs, where N is `escalate_after_consecutive`.
monitor.warnA run completed with problems. The monitor is still available and no incident opens.
monitor.persistent_warningsConsecutive warns crossed `warn_escalate_after`. This one does open an incident.
monitor.warn_digestThe batched roll-up of warnings for one monitor, sent on the digest schedule rather than per run.
fleet.breachedA 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.recoveredA fleet dropped back below its threshold. Names the instances that returned.
monitor.completion_budgetA 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-Typeapplication/json
X-PulseWatch-Eventmonitor.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-Deliveryunique UUID per delivery attempt (retries get a new one)
X-PulseWatch-Signaturesha256=<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-Delivery id and is logged to your alert history.
  • The "Test" button on a channel sends a realistic signed monitor.down sample.
  • Secret rotation: generate a new signing secret in Channels; deliveries switch immediately, so roll it into your verifier first.