Skip to content

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.

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"
}
FieldMeaning
kind"down" when an incident opens, "up" when it resolves.
monitorThe monitor that changed state: id, name, url, type.
incidentThe incident record, or null. On a recovery, resolvedAt and durationSeconds are populated; cause is the error from the failing check.
timestampISO-8601 time the alert was generated.
appUrlBase URL of your Aloft install, for building links back to the app.

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.

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:

HeaderValue
X-UR-Signaturesha256=<hex> — the HMAC (see below).
X-UR-TimestampUnix time (seconds) when the request was signed.
X-UR-NonceA random per-delivery value.
X-UR-Eventmonitor.down or monitor.up.

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 + "." + rawBody
signature = "sha256=" + HMAC_SHA256(secret, signingString) // hex-encoded

To 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()

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);
}

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-Timestamp and X-UR-Nonce that 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.