Outbound webhooks
Send signed alert events to an HTTPS endpoint.
The payload
Each request contains an event name, Unix timestamp, and event data.
{
"event": "alert.triggered",
"ts": 1700000000,
"data": {
"alert_id": "01KX…",
"title": "Disk 91% full on db-1",
"…": "…"
}
}
Available events are alert.triggered, alert.acked, alert.escalated, alert.resolved, and alert.unreachable. Select events when creating or editing the webhook.
Verifying the signature
Verify the x-acked-signature header before processing a request.
x-acked-signature: t=1700000000,v1=<hex>,v2=<base64>
The header contains comma-separated key=value fields. t is the signed Unix timestamp. v1 is always present. v2 is present after an Ed25519 signing key is generated. Parse fields by name and reject duplicates.
v1 — HMAC-SHA256
Sign the timestamp, a literal dot, and the exact request body:
signed_string = "{t}.{raw_body}"
v1 = hex(hmac_sha256(signing_secret, signed_string))
Verify the raw bytes before parsing JSON. Re-serialization can change key order, whitespace, or escaping.
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"])
Compare signatures in constant time. The example uses compare_digest.
v2 — Ed25519
v2 signs the same bytes as v1. The signature and 32-byte raw Ed25519 public key are base64-encoded. Copy the public key from the webhook detail page.
signed_string = "{t}.{raw_body}"
v2 = base64(ed25519_sign(private_key, signed_string))
The public key can be stored with the receiver. Acked keeps the private key in its credential service.
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 raw request-body Buffer to verifyV2.
Rejecting replays
Reject timestamps outside your replay window. Five minutes is a reasonable default. The timestamp is signed, so it cannot be changed without invalidating the signature.
Use v2 for new receivers. Acked continues to send v1 for compatibility. Webhooks without an Ed25519 key send only v1.
Rotating the Ed25519 key
Generate or rotate the key from the webhook detail page. Rotation takes effect immediately. The signature header has no key id and does not include an overlap signature from the old key.
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 is unchanged by Ed25519 rotation. Schedule a maintenance window if the receiver cannot temporarily accept v1.
Retries
Failed deliveries receive up to eight attempts with backoff.
- Return
2xxpromptly and process work asynchronously. - Make processing idempotent using
alert_idandevent.
Acked does not follow redirects. Update the configured URL when an endpoint moves.
Custom headers
Add custom headers such as Authorization when required. Values are encrypted at rest and shown only when entered; the dashboard later lists names only. Custom headers cannot override the Acked signature or content type.