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.
Project webhooks
Section titled “Project webhooks”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.
Events
Section titled “Events”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. |
About monitor.degraded
Section titled “About monitor.degraded”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.
Payload
Section titled “Payload”Every delivery is a JSON POST with the same envelope: event, timestamp, projectId, and an event-specific data object.
Status events
Section titled “Status events”monitor.up, monitor.down, and monitor.degraded carry the monitor and the check that changed its state:
{ "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 events
Section titled “Incident events”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
Section titled “Lifecycle events”Lifecycle events have a different data shape:
monitor.created,monitor.updated,monitor.paused, andmonitor.resumedcarry the whole monitor object indata.monitor. Itsidis a number, not ansrv_ID.monitor.pausedandmonitor.resumedfire however the monitor was paused or resumed: in the dashboard, through the API, or from the MCP server.monitor.deletedcarriesdata.monitorId, the deleted monitor’s number.
Delivery
Section titled “Delivery”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
2xxresponse. Redirects aren’t followed, so a3xxcounts 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.
Verifying deliveries
Section titled “Verifying deliveries”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.
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
Section titled “Delivery log”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.
Notification webhooks (legacy)
Section titled “Notification webhooks (legacy)”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.