Alerting & Notifications

Verifying Webhook Signatures

Authenticate Fleetera webhook requests with HMAC, and understand delivery and retry behavior.

Because your webhook endpoint is reachable on the public internet, you must verify that each request genuinely came from Fleetera before acting on it. Fleetera signs every request with your channel's signing secret using HMAC-SHA256, and your receiver recomputes the signature to confirm it matches.

Request headers

Every webhook delivery carries these headers:

HeaderDescription
X-Fleetera-SignatureThe signature, in the form sha256=<hex>.
X-Fleetera-Signature-TimestampThe signature timestamp, as Unix time in seconds. Part of the signed material.
X-Fleetera-DeliveryThe delivery ID — the same value as the payload's id, stable across retries.
X-Fleetera-EventThe event type — alert.triggered or alert.resolved.

Fleetera also sends Content-Type: application/json, plus any custom headers configured on the channel.

How the signature is computed

Fleetera builds a signing string by joining the timestamp and the exact raw request body with a single . (period):

{timestamp}.{rawBody}

Where:

  • {timestamp} is the value of the X-Fleetera-Signature-Timestamp header (Unix seconds, as a string).
  • {rawBody} is the exact bytes of the JSON request body, unchanged.

It then computes HMAC-SHA256(secret, signingString), hex-encodes the result, and prefixes it with sha256=. That string is the value of the X-Fleetera-Signature header.

Compute your HMAC over the raw request body bytes, exactly as received. Do not parse the JSON and re-serialize it before signing — key ordering, whitespace, and number formatting will differ from the bytes Fleetera signed, and the signatures will not match. Capture the raw body in your web framework before any JSON body parser runs.

Verification steps

To verify a request:

  1. Read the X-Fleetera-Signature and X-Fleetera-Signature-Timestamp headers.
  2. Build the signing string: `${timestamp}.${rawBody}` using the raw body.
  3. Compute sha256= + the hex HMAC-SHA256 of that string with your channel secret.
  4. Compare your computed value to the X-Fleetera-Signature header using a constant-time comparison.
  5. Reject the request if the comparison fails, or if the timestamp is too far from the current time (replay defense — see below).

Reject stale timestamps

Because the timestamp is part of the signed material, an attacker who captures one valid request cannot alter the body without breaking the signature. To also stop an attacker from replaying a captured request verbatim, reject requests whose X-Fleetera-Signature-Timestamp is too far from your server's current time. A tolerance of about 5 minutes is a good default.

Node.js example

This example uses Express and captures the raw body so the HMAC is computed over the exact bytes received.

import express from "express";
import crypto from "node:crypto";

const SIGNING_SECRET = process.env.FLEETERA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60; // reject timestamps older than 5 minutes

const app = express();

// Capture the RAW body. The HMAC must be computed over these exact bytes —
// do not use a JSON parser before verifying the signature.
app.use(express.raw({ type: "application/json" }));

function verifyFleeteraSignature(req) {
  const signature = req.get("X-Fleetera-Signature");
  const timestamp = req.get("X-Fleetera-Signature-Timestamp");
  if (!signature || !timestamp) return false;

  // Replay defense: reject timestamps too far from now.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const rawBody = req.body; // a Buffer, thanks to express.raw()
  const signingString = `${timestamp}.${rawBody.toString("utf8")}`;
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", SIGNING_SECRET).update(signingString).digest("hex");

  // Constant-time compare. timingSafeEqual throws if lengths differ, so guard first.
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/fleetera-alerts", (req, res) => {
  if (!verifyFleeteraSignature(req)) {
    return res.status(401).send("invalid signature");
  }

  const payload = JSON.parse(req.body.toString("utf8"));

  // Deduplicate on the delivery id (stable across retries).
  // alreadyProcessed(payload.id) is your own idempotency check.
  if (alreadyProcessed(payload.id)) {
    return res.status(200).send("ok"); // already handled — acknowledge and stop
  }

  // Acknowledge fast, then process asynchronously.
  res.status(200).send("ok");
  void handleAlert(payload); // enqueue / process out of the request path
});

app.listen(3000);

Python example

This example uses Flask and reads the raw body via request.get_data().

import hashlib
import hmac
import os
import time

from flask import Flask, request

SIGNING_SECRET = os.environ["FLEETERA_WEBHOOK_SECRET"].encode("utf-8")
TOLERANCE_SECONDS = 5 * 60  # reject timestamps older than 5 minutes

app = Flask(__name__)


def verify_fleetera_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
    if not signature or not timestamp:
        return False

    # Replay defense: reject timestamps too far from now.
    try:
        age = abs(int(time.time()) - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    # Sign the EXACT raw bytes — do not re-serialize parsed JSON.
    signing_string = f"{timestamp}.".encode("utf-8") + raw_body
    digest = hmac.new(SIGNING_SECRET, signing_string, hashlib.sha256).hexdigest()
    expected = f"sha256={digest}"

    # Constant-time comparison.
    return hmac.compare_digest(expected, signature)


@app.post("/fleetera-alerts")
def fleetera_alerts():
    raw_body = request.get_data()  # exact bytes received
    signature = request.headers.get("X-Fleetera-Signature", "")
    timestamp = request.headers.get("X-Fleetera-Signature-Timestamp", "")

    if not verify_fleetera_signature(raw_body, signature, timestamp):
        return "invalid signature", 401

    payload = request.get_json()

    # Deduplicate on the delivery id (stable across retries).
    if already_processed(payload["id"]):
        return "ok", 200

    enqueue_alert(payload)  # process out of the request path
    return "ok", 200

The f"{timestamp}.".encode("utf-8") + raw_body construction keeps the body as raw bytes rather than decoding it to a string and re-encoding it. This avoids any chance of altering the exact bytes Fleetera signed.

Delivery semantics

Understanding how Fleetera decides a delivery succeeded — and what it does when it does not — will help you build a robust receiver.

What counts as delivered

A delivery is successful when your endpoint responds with a 2xx status code within the per-attempt timeout of 10 seconds (connect plus response). Fleetera reads only your status code; the response body is ignored.

Retries

If an attempt fails transiently, Fleetera retries with exponential backoff and jitter — up to 5 attempts total, spread over roughly 30 seconds to 8 minutes. Retries reuse the same delivery id, so deduplicating on id keeps retries from producing duplicates.

An attempt is retried when:

  • The connection fails or times out (no response within 10 seconds).
  • Your endpoint responds with 429 Too Many Requests.
  • Your endpoint responds with any 5xx status.

An attempt is a permanent failure (no retry) when:

  • Your endpoint responds with a 4xx status other than 429. A 4xx is treated as a configuration problem on the receiving side — for example, a wrong path or a rejected authorization header.

After the retry budget is exhausted, the delivery is marked failed and Fleetera stops trying. Retries are durable: a delivery still owed a retry survives a Fleetera restart.

Requirements on your receiver

To receive Fleetera webhooks, your endpoint must:

  • Use HTTPS. Plain http:// URLs are rejected when the channel is created.
  • Be publicly resolvable. The URL's host must resolve to a public IP address. Fleetera refuses to connect to private, loopback, link-local, or other internal addresses — including RFC 1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), 127.0.0.0/8, the cloud metadata address 169.254.169.254, carrier-grade NAT (100.64.0.0/10), IPv6 loopback/unique-local/link-local, and internal hostnames such as localhost and *.svc.cluster.local. This check is re-applied at connection time on every attempt, so a hostname cannot resolve to a public address at setup and then a private one at delivery.
  • Respond quickly. Return a 2xx within 10 seconds. Acknowledge the request first, then do the real work asynchronously (enqueue a job, push to a worker). Long-running processing inside the request risks a timeout, which Fleetera treats as a failure and retries.

If your receiver does slow work (creating a ticket, paging an on-call engineer, calling another API) inside the request handler, you may exceed the 10-second timeout. Fleetera will then retry — and because the original request may still have succeeded on your side, you can end up with duplicates. Acknowledge fast, deduplicate on id, and process out of band.

On this page