Skip to content

Webhooks

Webhooks send status changes to your own endpoints, such as a Slack relay, PagerDuty, or a Lambda function. TunnelHQ sends one event when a monitor goes down and another when it recovers.

Set up project webhooks under Developers → Webhooks. Add an endpoint URL, choose the events it receives, and optionally give it a secret so you can verify deliveries.

Project webhooks are edge-triggered: you get exactly one monitor.down when a monitor goes from up to down, and exactly one monitor.up when it recovers. A new monitor’s first check also sends an event: monitor.up if it’s healthy, or monitor.down (or monitor.degraded, if it’s retrying) if it fails.

Subscribe each endpoint to the events it needs.

Group Event Sent when
Monitors monitor.down A monitor goes down.
monitor.degraded A monitor fails a check and starts retrying (see below).
monitor.up A monitor recovers, or a new monitor’s first check passes.
monitor.created A monitor is created.
monitor.updated A monitor is changed.
monitor.deleted A monitor is deleted.
monitor.paused A monitor is paused.
monitor.resumed A monitor is resumed.
Incidents incident.created An incident opens.
incident.resolved An incident closes.

monitor.degraded is a retry signal, not a partial-failure signal. It fires when a monitor fails a check and starts retrying before it’s declared down, so a monitor with a single location emits it on a brief network blip. It isn’t limited to servers that fail in some locations and pass in others.

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 only ends a brief retry is delivered only to endpoints subscribed to the degraded event, so it won’t reach you.

Every delivery is a JSON POST with the same envelope: event, timestamp, projectId, and an event-specific data object.

monitor.up, monitor.down, and monitor.degraded carry the monitor and the check that changed its state:

monitor.down
{
"event": "monitor.down",
"timestamp": "2026-06-14T08:05:10.000Z",
"projectId": 42,
"data": {
"monitor_id": "srv_916",
"monitor_name": "Sydney-I",
"protocol": "openconnect",
"protocols": ["openconnect"],
"failed_protocols": ["openconnect"],
"host": "203.0.113.15",
"port": 443,
"time": "2026-06-14 08:05:10.123",
"latency_ms": null,
"connect_ms": null,
"message": "Tunnel unreachable",
"failed_stage": "connect",
"diagnostic": "…",
"region": "any",
"retries": 2
}
}
Field Description
monitor_id The monitor’s ID, such as srv_916.
monitor_name The monitor’s name.
protocol The monitor’s protocol.
protocols The monitor’s protocols, as an array.
failed_protocols The protocols that failed, as an array, or null.
host, port The server address.
time When the check ran, in UTC, without a time zone marker (2026-06-14 08:05:10.123).
latency_ms One round trip through the tunnel, in milliseconds, or null if the check failed.
connect_ms How long the tunnel took to come up, in milliseconds, or null if the check failed.
message A short summary of the result.
failed_stage The test stage that failed, such as connect.
diagnostic The checker’s detail, such as amneziawg handshake not established (peer not responding).
region The location the check ran from, as a region code such as any or pk.
retries The number of failed checks behind the change.

timestamp in the envelope is ISO 8601 with a Z. time in data has no zone marker, so treat it as UTC.

incident.created and incident.resolved carry every status field above, plus:

Field Description
incident_id The incident’s ID, such as inc_….
incident_status The incident’s status.
incident_started_at When the incident opened.
incident_ended_at When the incident closed.

Lifecycle events have a different data shape:

  • monitor.created, monitor.updated, monitor.paused, and monitor.resumed carry the whole monitor object in data.monitor. Its id is a number, not an srv_ ID.
  • monitor.paused and monitor.resumed fire however the monitor was paused or resumed: in the dashboard, through the API, or from the MCP server.
  • monitor.deleted carries data.monitorId, the deleted monitor’s number.

Every delivery sends these headers:

Header Value
Content-Type application/json
User-Agent TunnelHQ-Webhook/1.0
X-TunnelHQ-Event The event name, such as monitor.down.
X-TunnelHQ-Endpoint-Id The endpoint’s ID.
X-TunnelHQ-Signature The signature. Only sent when the endpoint has a secret.
X-API-Key-Id Only sent when the endpoint is linked to an API key.
  • Success: any 2xx response. Redirects aren’t followed, so a 3xx counts as a failure.
  • Timeout: each attempt waits up to 5 seconds.
  • Retries: a failed delivery is retried 3 more times, 1 second apart, for 4 attempts in total.
  • Auto-disable: after 50 failed deliveries in a row, the endpoint is switched off. It stays off until someone turns it back on; a later successful delivery doesn’t re-enable it.
  • Public addresses only: TunnelHQ delivers only to public addresses. An endpoint that resolves to a private address is refused, and this is checked again on every attempt.

If you give an endpoint a secret, every delivery to it is signed. The X-TunnelHQ-Signature header contains sha256=<hex>: an HMAC-SHA256 of the JSON body exactly as sent, keyed with your secret. Recompute the HMAC over the raw body and compare the two before trusting the payload. Reject any request whose signature header is missing or malformed: an attacker controls that header, and a genuine delivery to a signed endpoint always sends a well-formed one.

verify.js
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);
}

Each endpoint’s page (Developers → Webhooks, then the endpoint) lists every delivery with its timestamp and response. Filter by All, Success, or Failure, expand a delivery to see what was sent, and redeliver it if you need to. The same page reveals and regenerates the endpoint’s signing secret.

To check whether a webhook fired, use the delivery log. The “still down” heartbeat rows in the dashboard are status updates, not webhook calls.

There’s a second, older webhook under Integrations → Add Integration → Webhook, inherited from the monitoring engine TunnelHQ is built on. Like the other notification channels, it fires on changes of state.

Repeated reminders during an outage come from a per-monitor setting, not from the webhook: Resend Notification if Down X times consecutively (0 turns it off). Set it in a monitor’s settings, or for several monitors at once in the bulk settings on the Monitors page. It repeats the alert on every channel that monitor uses, not only webhooks.