Webhooks and signing
The webhook channel POSTs a JSON document to a URL you control every time a monitor goes down or recovers. Use it to drive anything Aloft doesn’t integrate with directly — a chatbot, a ticketing system, your own automation.
See Channels for how to add a webhook channel. This page documents what Aloft sends and how to verify it.
The payload
Section titled “The payload”Aloft sends a JSON body with Content-Type: application/json. The shape is:
{ "kind": "down", "monitor": { "id": "mon_abc123", "name": "Marketing site", "url": "https://example.com", "type": "http" }, "incident": { "id": "inc_def456", "startedAt": "2026-06-02T14:31:00.000Z", "resolvedAt": null, "durationSeconds": null, "cause": "Connection timed out" }, "timestamp": "2026-06-02T14:31:05.000Z", "appUrl": "https://uptime.example.com"}| Field | Meaning |
|---|---|
kind | "down" when an incident opens, "up" when it resolves. |
monitor | The monitor that changed state: id, name, url, type. |
incident | The incident record, or null. On a recovery, resolvedAt and durationSeconds are populated; cause is the error from the failing check. |
timestamp | ISO-8601 time the alert was generated. |
appUrl | Base URL of your Aloft install, for building links back to the app. |
Method and custom headers
Section titled “Method and custom headers”By default Aloft sends a POST. You can choose PUT or PATCH instead when
you create the channel. Any custom headers you configure on the channel are
merged into the request alongside Content-Type and the signature headers
below.
Verifying the signature
Section titled “Verifying the signature”If you set a signing secret on the channel, Aloft signs every request so your receiver can prove the alert genuinely came from Aloft — and isn’t a spoofed or replayed message. When a secret is set, Aloft adds these headers:
| Header | Value |
|---|---|
X-UR-Signature | sha256=<hex> — the HMAC (see below). |
X-UR-Timestamp | Unix time (seconds) when the request was signed. |
X-UR-Nonce | A random per-delivery value. |
X-UR-Event | monitor.down or monitor.up. |
How the signature is computed
Section titled “How the signature is computed”Aloft builds the signing string by joining the timestamp, the nonce, and the raw request body with literal dots:
signingString = X-UR-Timestamp + "." + X-UR-Nonce + "." + rawBodysignature = "sha256=" + HMAC_SHA256(secret, signingString) // hex-encodedTo verify, recompute the same HMAC on your side and compare it to the
X-UR-Signature header using a constant-time comparison:
function verify(request, secret): timestamp = request.header["X-UR-Timestamp"] nonce = request.header["X-UR-Nonce"] received = request.header["X-UR-Signature"] # "sha256=<hex>" rawBody = request.body # the exact bytes, NOT re-serialized
# 1. Reject stale requests (replay protection). if abs(now_unix() - integer(timestamp)) > 300: # 5-minute window reject("timestamp outside allowed skew")
# 2. Recompute and compare. signingString = timestamp + "." + nonce + "." + rawBody expected = "sha256=" + hex(hmac_sha256(secret, signingString)) if not constant_time_equals(expected, received): reject("signature mismatch")
accept()Node.js example
Section titled “Node.js example”This matches exactly how Aloft signs (crypto.createHmac("sha256", …) over
`${timestamp}.${nonce}.${body}` with a hex digest):
import { createHmac, timingSafeEqual } from "node:crypto";
/** * @param {string} rawBody The raw request body bytes (e.g. from express.raw()). * @param {Record<string,string>} headers Incoming request headers. * @param {string} secret The signing secret you configured on the channel. */function verifyAloftWebhook(rawBody, headers, secret) { const timestamp = headers["x-ur-timestamp"]; const nonce = headers["x-ur-nonce"]; const received = headers["x-ur-signature"]; // "sha256=<hex>" if (!timestamp || !nonce || !received) return false;
// Replay protection: reject requests outside a ±5-minute window. const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(skew) || skew > 300) return false;
const signingString = `${timestamp}.${nonce}.${rawBody}`; const expected = "sha256=" + createHmac("sha256", secret).update(signingString).digest("hex");
const a = Buffer.from(expected); const b = Buffer.from(received); return a.length === b.length && timingSafeEqual(a, b);}Why verification matters
Section titled “Why verification matters”Your webhook endpoint is a URL on the public internet. Anyone who learns it can POST to it and impersonate Aloft — opening fake incidents, triggering downstream automation, or paging your team. Verifying the signature defends against two distinct attacks:
- Spoofing. Only Aloft and your receiver know the signing secret, so only a
request signed with that secret can produce a matching
X-UR-Signature. An attacker without the secret can’t forge one. - Replay. Each request carries a fresh
X-UR-TimestampandX-UR-Noncethat are part of the signed string. By rejecting requests whose timestamp is outside a short window (Aloft recommends ±5 minutes), you stop someone from capturing a real alert and re-sending it later. For stricter protection, also remember recently-seen nonces and reject duplicates.
Where to go next
Section titled “Where to go next”- Channels — add and test a webhook channel.
- Delivery log and replay — inspect what Aloft sent and re-send after a fix.