/ 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:

A triggered event for a missed heartbeat
{
  "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. type is monitor or heartbeat; target is the monitored URL or host, and null for a heartbeat.
  • incident — on triggered and resolved: the incident id, its cause (such as TIMEOUT, HTTP_5XX or HEARTBEAT_MISSED), a human-readable cause_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.

Verify the signature (Node.js)
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);
}
Verify the signature (Python)
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.