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:

  1. Temporarily keep v1 verification enabled.
  2. Rotate the key and copy the new public key.
  3. Deploy the new public key to your receiver, then require v2 again.

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.

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.