/ docs / webhooks
Webhook alerts: payload, signature and retries
What a Fettle webhook delivery contains: the headers, the versioned JSON body, the HMAC signature to verify, and when a failed delivery is retried.
Updated
Add a webhook channel
Webhooks are a notification channel, from Plus. Add one in the dashboard under Channels: give it a name and the URL Fettle should POST to. The URL must be public HTTPS; an address that resolves to a private or local network is refused when you save it and again before each delivery.
Saving the channel shows its signing secret once. Store it where your receiver reads it; Fettle cannot show it again, and a lost secret means deleting the channel and adding a new one. A monitor or heartbeat with no channel list of its own alerts every verified, active channel, this one included; one with a list alerts only those. Send a test event from the channel to check your receiver before a real incident does.
Headers
Each delivery is one POST with these headers:
Content-Type- application/json
User-Agent- Fettle-Webhook/1
X-Fettle-Event- The event: triggered, resolved, ssl_expiry or test.
X-Fettle-Timestamp- Unix seconds at the moment of sending, also signed.
X-Fettle-Signature- sha256= and the hex HMAC-SHA256 described below.
X-Fettle-Delivery- The delivery id. It stays the same on every retry, so use it to drop duplicates.
Payload
The body is JSON, version "1". The version changes only with a breaking change to its shape. Fettle sends it compact, with sorted keys:
{
"event": "triggered",
"incident": {
"cause": "HEARTBEAT_MISSED",
"cause_detail": "Missed expected heartbeat",
"id": "0192f3a4-5b6c-7d8e-9f01-23456789abcd",
"resolved_at": null,
"started_at": "2026-10-26T03:47:30.120000+00:00"
},
"organization": { "id": "0192f3a4-0000-7000-8000-000000000001" },
"source": {
"id": "0192f3a4-1111-7111-8111-111111111111",
"name": "Nightly backup",
"target": null,
"type": "heartbeat"
},
"ssl": null,
"timestamp": "2026-10-26T03:47:30.480000+00:00",
"version": "1"
}source— what the event is about.typeismonitororheartbeat;targetis the monitored URL or host, and null for a heartbeat.incident— on triggered and resolved: the incident id, itscause(such asTIMEOUT,HTTP_5XXorHEARTBEAT_MISSED), a human-readablecause_detail, and when it started and resolved. New causes are added over time, so treat an unknown one as a generic failure.ssl— on ssl_expiry only, null otherwise.timestamp— when the event happened, ISO 8601 in UTC.
triggered- An incident opened on a monitor or heartbeat. A repeat alert for an incident still open is another triggered delivery with the same incident id.
resolved- The incident closed; incident.resolved_at is set.
ssl_expiry- An SSL monitor's certificate entered its warning window. incident is null; ssl carries days_left, expires_at, issuer and subject.
test- Sent by the test button on the channel. source.name is "Fettle test event" and its id is random.
Verify the signature
X-Fettle-Signature is sha256= followed by the hex HMAC-SHA256, keyed with the channel’s signing secret, of the timestamp header, a dot, and the raw body: HMAC(secret, timestamp + "." + body).
Compute it over the body bytes exactly as they arrived. Parsing the JSON and serializing it again changes the bytes and breaks the match. Compare in constant time, and reject a timestamp more than five minutes from your clock so a captured delivery cannot be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a Buffer), before any JSON parsing.
export function verifyFettleWebhook(rawBody, headers, secret) {
const timestamp = headers["x-fettle-timestamp"];
const signature = headers["x-fettle-signature"] ?? "";
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"sha256=" +
createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib
import hmac
import time
def verify_fettle_webhook(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Fettle-Timestamp", "")
signature = headers.get("X-Fettle-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest(signature, "sha256=" + digest.hexdigest())Responses and retries
Answer with any 2xx within 10 seconds and the delivery counts as done. Do slow work after responding, not before.
A 429, a 5xx, a timeout, a DNS failure or a dropped connection is retried up to 5 times, after 10, 20, 40, 80 and 160 seconds. Any other answer, including a redirect or a 4xx, is final: Fettle does not follow redirects and does not retry a request your server rejected.