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.down | monitor | A protocol on a probe went from up to down. One event per (protocol, probe), not per monitor. |
| monitor.up | monitor | Recovered from down. |
| monitor.degraded | monitor | Connected but crossed a degraded performance threshold. |
| monitor.ip_mismatch | monitor | Exit IP doesn't match expected_exit_ip. |
| monitor.created | monitor | A monitor was created. |
| monitor.updated | monitor | A monitor's configuration changed. |
| monitor.deleted | monitor | A monitor was deleted. |
| monitor.paused | monitor | A monitor was deactivated. |
| monitor.resumed | monitor | A monitor was reactivated. |
| incident.created | incident | Fires alongside down/degraded/ip_mismatch — but per (protocol, probe), so expect one per affected probe during a fleet-wide outage. |
| incident.resolved | incident | The incident closed. |
| incident.correlated | incident | Fires instead of incident.created when 2+ incidents open within 2 minutes on monitors sharing a probe — one event covers the group. |
| check.failed | check | Per-probe, fires on a state change only — not on every single check. |
| check.succeeded | check | Same, for recovery. |
| vps.resource_high | vps | A CPU/memory/disk threshold breach sustained past its configured duration. |
| vps.resolved | vps | A resource breach cleared. |
| vps.offline | vps | The VPS agent stopped reporting. |
| vps.recovered | vps | The 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.status | string | This specific (protocol, probe) pair's own status — what just transitioned. This is what triggered the webhook, not the server's overall health. |
| monitor.overall_status | string | The 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_status | string | This 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_protocol | number | null | How many assigned probes currently report this protocol down. |
| monitor.probes_total_for_protocol | number | null | How many probes are assigned to check this protocol at all. |
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.
{
"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
}{
"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
}{
"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”: true and a fixed sample monitor named “Sample Server (test)” — filter these out of any automation that acts on real incidents.Headers
| X-ExitProbe-Event | header | The event name, e.g. monitor.down. |
| X-ExitProbe-Delivery | header | This delivery's id — matches delivery_id in the body, and the id you'll see in Delivery History. |
| X-ExitProbe-Timestamp | header | When this delivery was sent. |
| X-ExitProbe-Signature | header | sha256=<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-Previous | header | Same, but keyed by the previous secret — only present during the 24h grace window right after Rotate Secret. |
| User-Agent | header | ExitProbe-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.
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)
);
}$expected = 'sha256=' . hash_hmac(
'sha256',
$request->getContent(), // raw body
$secret
);
if (! hash_equals($expected, $request->header('X-ExitProbe-Signature'))) {
abort(401);
}import hmac, hashlib
expected = 'sha256=' + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
hmac.compare_digest(expected, signature_header)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;
}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.