Outbound webhooks
Every alert transition can be POSTed to an endpoint you control.
The payload
One envelope for every event. event names the transition, data carries the alert.
{
"event": "alert.triggered",
"ts": 1700000000,
"data": {
"alert_id": "01KX…",
"title": "Disk 91% full on db-1",
"…": "…"
}
}
Events include alert.triggered, alert.acked, alert.escalated and alert.resolved. Subscribe to the ones you want when you create the webhook.
Verifying the signature
Every delivery carries a signature header. Check it. Your endpoint is reachable by anyone who learns the URL, and the signature is what distinguishes a real delivery from a forged one.
x-acked-signature: t=1700000000,v1=<hex>,v2=<base64>
The parts are comma-separated key=value pairs. t is the Unix timestamp we signed at. v1 is present on every delivery. v2 is present when the webhook has an Ed25519 signing key. Read the parts by name rather than by position, and reject duplicate names.
v1 — HMAC-SHA256
The signed string is the timestamp, a literal dot, and the exact raw request body:
signed_string = "{t}.{raw_body}"
v1 = hex(hmac_sha256(signing_secret, signed_string))
Sign the bytes you received, before any JSON parsing or re-serialization. Re-encoding changes them — key order, whitespace, unicode escaping — and the signature will not match.
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = {}
for part in header.split(","):
name, separator, value = part.partition("=")
if not separator or name in parts:
return False
parts[name] = value
if "t" not in parts or "v1" not in parts:
return False
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Use a constant-time comparison — compare_digest above, not ==.
v2 — Ed25519
v2 signs the same bytes as v1. The signature is base64-encoded. Its matching public key is the base64-encoded, 32-byte raw Ed25519 key shown on the webhook's detail page.
signed_string = "{t}.{raw_body}"
v2 = base64(ed25519_sign(private_key, signed_string))
The public key is not a secret. Acked stores it with the webhook and shows it whenever you need to copy it. The private key stays sealed in Acked's credential worker.
import { webcrypto } from "node:crypto";
function signatureParts(header) {
const out = {};
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i <= 0) throw new Error("invalid signature header");
const name = part.slice(0, i);
if (Object.hasOwn(out, name)) throw new Error("duplicate signature part");
out[name] = part.slice(i + 1);
}
return out;
}
async function verifyV2(rawBody, header, publicKey) {
const parts = signatureParts(header);
if (!parts.t || !parts.v2) return false;
const signed = Buffer.concat([Buffer.from(`${parts.t}.`), rawBody]);
const key = await webcrypto.subtle.importKey(
"raw",
Buffer.from(publicKey, "base64"),
{ name: "Ed25519" },
false,
["verify"],
);
return webcrypto.subtle.verify(
{ name: "Ed25519" },
key,
Buffer.from(parts.v2, "base64"),
signed,
);
}
Pass the exact request-body Buffer to verifyV2. Do not parse and re-encode JSON first.
Rejecting replays
The timestamp is inside the signed string, so it cannot be altered without breaking the signature. Reject deliveries whose t is far from your clock — five minutes is a reasonable window. Without that check, a captured delivery can be replayed at any time.
Use v2 for new receivers. v1 remains alongside it for compatibility. Existing webhooks without an Ed25519 key send v1 only until you generate a signing key from the webhook's detail page.
Rotating the Ed25519 key
Generate or rotate the key from the webhook's detail page. Rotation immediately replaces the key; the previous public key stops verifying v2 deliveries, and the header carries no key id or overlap signature.
If your receiver already requires v2, coordinate the change:
- Temporarily keep
v1verification enabled. - Rotate the key and copy the new public key.
- Deploy the new public key to your receiver, then require
v2again.
v1 continues unchanged during an Ed25519 rotation. If you cannot temporarily accept v1, arrange a maintenance window before rotating.
Retries
A delivery that fails is retried with backoff. Two things follow:
- Answer quickly. Return 2xx as soon as you have the payload and do the work afterwards. A slow endpoint is treated as a failed one.
- Be idempotent. A retry can arrive after your first attempt actually succeeded. Key on
alert_idplusevent.
Redirects are not followed. A 3xx is a failed delivery — we only ever POST to the host you configured, so an endpoint that moved needs its URL updated here.
Custom headers
A webhook can carry headers your endpoint requires, an Authorization among them. Values are encrypted at rest and are never shown again after you save them — the dashboard lists the header names only.
They cannot override the signature or content type.