Skip to content

Webhook Alerts

You will be able to add a Generic webhook alert channel that POSTs a signed JSON envelope to your own HTTPS endpoint on every incident transition, and verify each delivery’s signature so you can trust that it came from TMXIO. This is the right channel for custom receivers and middleware bridges (Zapier, n8n, or a self-hosted transformer in front of PagerDuty and similar tools).

Your receiver must be a public HTTPS endpoint on port 443. TMXIO will refuse URLs that point at private, loopback, link-local, or cloud-metadata addresses, and it never follows redirects — point the channel directly at the receiver. Respond with any 2xx status within 8 seconds to acknowledge a delivery.

If your endpoint returns 408 or 429, or a 5xx error, TMXIO retries the delivery a few times with backoff. Any other 4xx response is treated as a permanent failure: the delivery is marked failed, the channel surfaces the error in the dashboard, and account owners and admins receive a one-time email nudge. The channel is never disabled automatically — fix the receiver and the next incident will deliver again.

Every delivery is a JSON document with a top-level version. Within version 1, changes are additive only — new fields may appear, but existing fields will not change meaning or disappear. A breaking change would ship as a new version.

{
"version": 1,
"event": "incident.opened",
"delivery_id": "9d2f6c1e-8a41-4b6e-9c1f-2f4a8f0f6b7d",
"sent_at": "2026-07-03T14:00:00Z",
"tenant": { "slug": "acme" },
"incident": {
"id": "1042",
"alert_type": "down",
"severity": "critical",
"status": "open",
"title": "1 site is down",
"description": "example.com is not responding.",
"sites": [
{
"slug": "example-site",
"environment": "prd",
"url": "https://example.com",
"status": "down"
}
],
"affected_count": 1,
"active_count": 1,
"recovered_count": 0,
"started_at": "2026-07-03T13:58:12Z",
"resolved_at": null
}
}
EventMeaning
incident.openedA new incident opened.
incident.updatedAn open incident changed (more sites affected, some recovered, …).
incident.resolvedThe incident fully recovered. Sent only when the channel’s “Recovery updates” setting is on.
incident.testA synthetic incident sent from the channel’s Send test button.

The event name is also sent in the X-TMXIO-Event request header, and the envelope’s delivery_id in X-TMXIO-Delivery.

Every request carries an HMAC signature header:

X-TMXIO-Signature: t=1751551200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is the Unix timestamp when the delivery was signed.
  • v1 is HMAC-SHA256(signing_secret, "{t}.{raw_body}"), hex-encoded.

The signing secret is generated when you create the channel and shown exactly once — store it in your receiver’s secret manager. To verify a delivery:

  1. Read the raw request body (before any JSON parsing or re-serialization).
  2. Split the header into t and v1.
  3. Recompute HMAC-SHA256 over the string "{t}.{raw_body}" with your signing secret.
  4. Compare against v1 using a constant-time comparison.
  5. Reject deliveries whose t is older than 5 minutes (or in the future by more than a small skew) to block replay attacks.
const crypto = require("node:crypto");
function verifyTmxioSignature(rawBody, signatureHeader, signingSecret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=", 2))
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
// Replay window: reject anything older than 5 minutes.
const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
if (ageSeconds > 300) return false;
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express example — capture the raw body, not the parsed JSON:
// app.post("/tmxio-webhook", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyTmxioSignature(req.body.toString("utf8"), req.get("X-TMXIO-Signature"), process.env.TMXIO_SIGNING_SECRET)) {
// return res.status(401).end();
// }
// const envelope = JSON.parse(req.body);
// res.status(200).end();
// });
<?php
function verify_tmxio_signature(string $rawBody, string $signatureHeader, string $signingSecret): bool
{
$parts = [];
foreach (explode(',', $signatureHeader) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$key] = $value;
}
$timestamp = (int) ($parts['t'] ?? 0);
// Replay window: reject anything older than 5 minutes.
if ($timestamp <= 0 || abs(time() - $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $signingSecret);
return hash_equals($expected, $parts['v1'] ?? '');
}
// Usage:
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_X_TMXIO_SIGNATURE'] ?? '';
// if (!verify_tmxio_signature($rawBody, $header, getenv('TMXIO_SIGNING_SECRET'))) {
// http_response_code(401);
// exit;
// }
// $envelope = json_decode($rawBody, true);

Use the channel’s Alert types checkboxes to choose which incident kinds are delivered, and the Recovery updates toggle to opt out of incident.resolved envelopes. There is no per-event filtering beyond that in envelope v1.