Webhook Alerts
Outcome
Section titled “Outcome”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).
Endpoint Requirements
Section titled “Endpoint Requirements”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.
The Envelope
Section titled “The Envelope”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 }}Event Types
Section titled “Event Types”| Event | Meaning |
|---|---|
incident.opened | A new incident opened. |
incident.updated | An open incident changed (more sites affected, some recovered, …). |
incident.resolved | The incident fully recovered. Sent only when the channel’s “Recovery updates” setting is on. |
incident.test | A 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.
Verifying Signatures
Section titled “Verifying Signatures”Every request carries an HMAC signature header:
X-TMXIO-Signature: t=1751551200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis the Unix timestamp when the delivery was signed.v1isHMAC-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:
- Read the raw request body (before any JSON parsing or re-serialization).
- Split the header into
tandv1. - Recompute
HMAC-SHA256over the string"{t}.{raw_body}"with your signing secret. - Compare against
v1using a constant-time comparison. - Reject deliveries whose
tis older than 5 minutes (or in the future by more than a small skew) to block replay attacks.
Node.js
Section titled “Node.js”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);Filtering
Section titled “Filtering”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.