Webhooks
Webhooks push status changes to your own endpoints — a Slack relay, PagerDuty, a Lambda, whatever you run. TunnelHQ sends a clean event when a monitor goes down and another when it recovers.
On this page
Project webhooks
Set these up under Developers → Webhooks. They're edge-triggered: you get exactly one monitor.down when a monitor goes from up to down, and exactly one monitor.up when it recovers.
They don't repeat during an outage
monitor.down at the start and one monitor.up at recovery — never a stream of "still down" calls. That's exactly what HTTP receivers like Slack, Discord, and PagerDuty expect.Events
Subscribe each endpoint to the events it cares about:
- Status —
monitor.up,monitor.down,monitor.degraded(a retry signal — see below). - Incidents —
incident.created,incident.resolved. - Lifecycle —
monitor.created,monitor.updated,monitor.deleted,monitor.paused,monitor.resumed.
monitor.degraded is a retry signal, not a partial-failure signal
Every
monitor.degraded is followed by a monitor.up when the monitor recovers, so a receiver never stays stuck in a degraded state. If you only want confirmed outages, subscribe to monitor.down and monitor.up and leave monitor.degraded off: a recovery that merely closes a brief retry is delivered only to endpoints that subscribed to the degraded event, so it won't reach you.Not to be confused with the monitor status also called degraded, which is a different thing: that one means no region completed a check and at least one was definitely refused — credentials or a certificate rejected. The event is about retrying; the status is about a confirmed configuration problem.
Payload
Every delivery is a JSON POST with the same fixed envelope — event, timestamp, projectId, and an event-specific data object:
{
"event": "monitor.down",
"timestamp": "2026-06-14T08:05:10.000Z",
"projectId": 42,
"data": {
"monitor_id": "srv_916",
"monitor_name": "Sydney-I",
"protocol": "openconnect",
"host": "95.111.222.15",
"port": 443,
"time": "2026-06-14T08:05:10.000Z",
"latency_ms": null,
"message": "Tunnel unreachable"
}
}Verifying deliveries
Give an endpoint a secret and every delivery is signed: the X-TunnelHQ-Signature header carries sha256=<HMAC-SHA256 of the raw body>. Recompute the HMAC with your secret and compare before trusting the payload — rejecting anything whose header is missing or malformed, which an attacker controls and a legitimate delivery never is. Deliveries also include X-TunnelHQ-Event and X-TunnelHQ-Endpoint-Id headers for routing.
const crypto = require("crypto");
function verify(rawBody, signatureHeader, secret) {
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const received = Buffer.from(String(signatureHeader ?? ""), "utf8");
const digest = Buffer.from(expected, "utf8");
// timingSafeEqual throws unless both buffers are the same length, so a
// missing or truncated header has to be rejected before it gets there.
// Comparing lengths first leaks nothing: the digest length is fixed.
return received.length === digest.length &&
crypto.timingSafeEqual(received, digest);
}Delivery log
Every outbound call is recorded with its timestamp and response, so you can see exactly what was sent and when. If you ever wonder whether a webhook fired, the delivery log is the source of truth — not the dashboard's "still down" heartbeat rows, which are status updates, not webhook calls.
Notification webhooks (legacy)
There's a second, older path under Settings → Notifications → Add Webhook, inherited from the monitoring engine TunnelHQ is built on. It also fires on the transition, but it has an optional resend interval — set it above zero and it re-sends every Nth failed check during an outage. It's off by default, which matches the project-webhook behavior above. Reach for it only if you specifically want repeat reminders while something is down.
Which should I use?