Webhooks & Events

The full event catalogue, the exact JSON payload ExitProbe POSTs to your webhook URL, and how to verify its HMAC-SHA256 signature. Configure a webhook via the Integrations API.

Events

An integration subscribes to any subset of these — pass them in the events array on Create Integration.

monitor.downmonitorA protocol on a probe went from up to down. One event per (protocol, probe), not per monitor.
monitor.upmonitorRecovered from down.
monitor.degradedmonitorConnected but crossed a degraded performance threshold.
monitor.ip_mismatchmonitorExit IP doesn't match expected_exit_ip.
monitor.createdmonitorA monitor was created.
monitor.updatedmonitorA monitor's configuration changed.
monitor.deletedmonitorA monitor was deleted.
monitor.pausedmonitorA monitor was deactivated.
monitor.resumedmonitorA monitor was reactivated.
incident.createdincidentFires alongside down/degraded/ip_mismatch — but per (protocol, probe), so expect one per affected probe during a fleet-wide outage.
incident.resolvedincidentThe incident closed.
incident.correlatedincidentFires instead of incident.created when 2+ incidents open within 2 minutes on monitors sharing a probe — one event covers the group.
check.failedcheckPer-probe, fires on a state change only — not on every single check.
check.succeededcheckSame, for recovery.
vps.resource_highvpsA CPU/memory/disk threshold breach sustained past its configured duration.
vps.resolvedvpsA resource breach cleared.
vps.offlinevpsThe VPS agent stopped reporting.
vps.recoveredvpsThe VPS agent reported in again.
vps.* events only ever fire on account-wide integrations (no project_id) — a VPS server has no project to scope a project-level integration to.

Regional vs. whole-server outages

A monitor can be assigned several probes. monitor.down fires once a failure for a protocol is confirmed: a second probe also sees it fail, or one probe keeps failing for a sustained period (30 minutes by default). A single failed check from a single probe never fires it — that is very often the probe's own network, and a different probe is asked to look straight away. It then fires once, and again only after the protocol has genuinely recovered (several clean checks in a row), never once per probe or per flap. That means monitor.down firing does not mean the server is unreachable everywhere: if you have probes in France and Japan and only France is failing, you'll get a monitor.down webhook while ExitProbe's own dashboard still shows the monitor up, because Japan still reaches it.

Don't infer "is this server down" from the event name alone — read monitor.scope in the payload below:

monitor.statusstringThis specific (protocol, probe) pair's own status — what just transitioned. This is what triggered the webhook, not the server's overall health.
monitor.overall_statusstringThe monitor's TRUE current status across every protocol and every assigned probe — exactly what the ExitProbe dashboard shows right now. A single probe failing never moves this off "up".
monitor.protocol_statusstringThis protocol's own aggregate verdict across all its assigned probes — "up" the instant even one probe still reaches it, "down" only once every assigned probe agrees.
monitor.scope"global" | "regional" | null"global": every probe assigned to this protocol agrees — a real outage/recovery. "regional": only the probe named in `probe` is affected; other probes still reach the server fine. null for events that aren't a status transition (e.g. monitor.ip_mismatch).
monitor.probes_down_for_protocolnumber | nullHow many assigned probes currently report this protocol down.
monitor.probes_total_for_protocolnumber | nullHow many probes are assigned to check this protocol at all.
Recommended integration behavior: only flip a downstream "is this server active" flag when monitor.overall_status (or monitor.protocol_status if you care about one specific protocol) says down — not merely because a monitor.down webhook arrived. Use scope: "regional" to show an informational "degraded in one region" banner instead of marking the whole server inactive.

Payload shape

Every webhook delivery POSTs this JSON body. The monitor key is present for monitor/incident/check events; a vps key replaces it for VPS events.

Regional monitor.down (France failing, Japan still up)
{
  "event": "monitor.down",
  "test": false,
  "timestamp": "2026-09-26T00:00:00Z",
  "monitor": {
    "id": 42,
    "name": "Dubai WG1",
    "status": "down",
    "overall_status": "up",
    "protocol_status": "up",
    "scope": "regional",
    "probes_down_for_protocol": 1,
    "probes_total_for_protocol": 2,
    "protocol": "wireguard",
    "exit_ip": "203.0.113.42",
    "exit_location": "AE",
    "latency_ms": 42,
    "packet_loss": null,
    "expected_exit_ip": "203.0.113.42",
    "is_active": true,
    "project_id": 3,
    "project_name": "Default Project"
  },
  "probe": { "id": 8, "name": "France", "location": "Paris" },
  "check_id": 9001,
  "previous_status": "up",
  "reason": "Connection timed out",
  "delivery_id": 123
}
Whole-server monitor.down (every assigned probe agrees)
{
  "event": "monitor.down",
  "test": false,
  "timestamp": "2026-09-26T00:00:00Z",
  "monitor": {
    "id": 42,
    "name": "Dubai WG1",
    "status": "down",
    "overall_status": "down",
    "protocol_status": "down",
    "scope": "global",
    "probes_down_for_protocol": 2,
    "probes_total_for_protocol": 2,
    "protocol": "wireguard",
    "exit_ip": null,
    "exit_location": null,
    "latency_ms": null,
    "packet_loss": null,
    "expected_exit_ip": "203.0.113.42",
    "is_active": true,
    "project_id": 3,
    "project_name": "Default Project"
  },
  "probe": { "id": 12, "name": "Japan", "location": "Tokyo" },
  "check_id": 9014,
  "previous_status": "up",
  "reason": "Connection timed out",
  "delivery_id": 124
}
VPS event
{
  "event": "vps.resource_high",
  "test": false,
  "timestamp": "2026-09-26T00:00:00Z",
  "vps": {
    "id": 7,
    "name": "prod-db-1",
    "hostname": "db1.internal",
    "ip_address": "203.0.113.9",
    "location": "Frankfurt",
    "os_name": "Ubuntu 24.04",
    "status": "online",
    "health_score": 61,
    "health_grade": "C",
    "cpu_usage": 92.4,
    "memory_usage": 71.2,
    "disk_usage": 44.0,
    "swap_usage": 12.0,
    "iowait_percent": 3.1,
    "steal_percent": 0.0,
    "load_1": 4.2,
    "cpu_cores": 4,
    "uptime_seconds": 8640000,
    "memory_total_bytes": 8589934592,
    "disk_total_bytes": 107374182400,
    "top_process": "postgres",
    "url": "https://app.exitprobe.com/dashboard/vps/7"
  },
  "delivery_id": 456
}
Test deliveries (from Send Test Notification) carry “test”: true and a fixed sample monitor named “Sample Server (test)” — filter these out of any automation that acts on real incidents.

Headers

X-ExitProbe-EventheaderThe event name, e.g. monitor.down.
X-ExitProbe-DeliveryheaderThis delivery's id — matches delivery_id in the body, and the id you'll see in Delivery History.
X-ExitProbe-TimestampheaderWhen this delivery was sent.
X-ExitProbe-Signatureheadersha256=<hex HMAC-SHA256 of the raw request body>, keyed by your webhook's current secret. Only sent if a secret is configured.
X-ExitProbe-Signature-PreviousheaderSame, but keyed by the previous secret — only present during the 24h grace window right after Rotate Secret.
User-AgentheaderExitProbe-Webhook/1.0

Verifying the signature

Compute an HMAC-SHA256 of the exact raw request body (before any JSON parsing) using your webhook’s secret, and compare it — in constant time — to the hex digest after sha256= in X-ExitProbe-Signature. During a secret rotation’s 24h grace window, also check against X-ExitProbe-Signature-Previous using your old secret, so deliveries verify correctly whichever secret signed them.

Node.js
import crypto from 'node:crypto';

function isValidSignature(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader),
    Buffer.from(expected)
  );
}
PHP (Laravel)
$expected = 'sha256=' . hash_hmac(
    'sha256',
    $request->getContent(), // raw body
    $secret
);

if (! hash_equals($expected, $request->header('X-ExitProbe-Signature'))) {
    abort(401);
}
Python
import hmac, hashlib

expected = 'sha256=' + hmac.new(
    secret.encode(), raw_body, hashlib.sha256
).hexdigest()

hmac.compare_digest(expected, signature_header)
Dart
import 'dart:convert';
import 'package:crypto/crypto.dart';

bool isValid(List<int> rawBody, String header, String secret) {
  final expected = 'sha256=' +
      Hmac(sha256, utf8.encode(secret)).convert(rawBody).toString();
  return expected == header;
}
Always verify against the raw request bytes, not a re-serialized JSON object — re-encoding (different key order, whitespace) will produce a different signature and every delivery will look invalid.

Rotating your secret

Calling POST /integrations/{id}/rotate-secret generates a new secret and returns it once. For 24 hours, deliveries are signed with both the new secret (X-ExitProbe-Signature) and the previous one (X-ExitProbe-Signature-Previous) — update your stored secret any time in that window without dropping a single valid delivery.