Webhook signatures

When you configure a webhook channel with a secret, IMAA signs every outbound request so your handler can verify it actually came from us. The scheme is the same one Stripe uses: HMAC-SHA256 over a timestamp-prefixed payload.

Request format

The body is JSON with three fields: event (the alert subject line),body (a multi-line text description), and timestamp (unix seconds). Formatted for reading, that's:

{
  "event": "[IMAA Alert] High TX Rate — WARNING",
  "body": "Contract: USDT (0xdAC1...)\nRule: High TX Rate\nMetric (tx_rate): 27\nThreshold: 10\nBlock range: 19123456-19123466",
  "timestamp": "1776384000"
}

On the wire, though, the body is serialized as compact, key-sorted JSON — no whitespace, keys in alphabetical order (body, then event, then timestamp). Those exact bytes are what the signature is computed over, so verify against the raw body you received rather than a re-serialized copy:

{"body":"Contract: USDT (0xdAC1...)\nRule: High TX Rate\nMetric (tx_rate): 27\nThreshold: 10\nBlock range: 19123456-19123466","event":"[IMAA Alert] High TX Rate — WARNING","timestamp":"1776384000"}

Headers on every request:

  • X-IMAA-Signature: sha256=<hex>
  • X-IMAA-Timestamp: <unix seconds>
  • Idempotency-Key: <delivery id> — see Idempotency
  • X-IMAA-Delivery-Id: <delivery id> (same value as the idempotency key)
  • Content-Type: application/json
  • User-Agent: IMAA-Webhook/1.0

Each HTTP attempt has a 10-second timeout. A delivery makes up to 3 attempts — the initial POST plus 2 retries, with ~1s then ~2s backoff. If all three fail, the delivery job itself is re-driven up to 3 more times, so a flapping endpoint can receive more than three POSTs overall. Every one of them carries the same Idempotency-Key (see Idempotency), so dedupe on that key rather than assuming a fixed attempt count.

Signature scheme

Compute HMAC-SHA256 over the bytes {timestamp}.{raw_body} using your configured secret. Compare the resulting hex digest against the value after sha256= in X-IMAA-Signature using a constant-time compare.

Critically: hash the raw request body bytes, not a re-serialized object. JSON whitespace and key order matter.

Verifying in Node.js

import crypto from "node:crypto";

export function verify(rawBody: Buffer, headers: Record<string, string>, secret: string) {
  const ts = headers["x-imaa-timestamp"];
  const sig = headers["x-imaa-signature"];
  if (!ts || !sig) return false;

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.`)
    .update(rawBody)
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(sig.replace("sha256=", "")), Buffer.from(expected));
}

Verifying in Python

import hmac, hashlib, time

def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    ts = headers.get("x-imaa-timestamp")
    sig = headers.get("x-imaa-signature", "")
    if not ts or not sig.startswith("sha256="):
        return False

    if abs(time.time() - int(ts)) > 300:
        return False

    mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256)
    return hmac.compare_digest(sig.removeprefix("sha256="), mac.hexdigest())

Replay protection

Reject any request where |now − X-IMAA-Timestamp| > 300 seconds. This bounds how long a captured request stays valid if an attacker ever gets hold of one. Five minutes is enough slack for normal clock drift without leaving a meaningful replay window.

Idempotency

Every delivery carries a stable Idempotency-Key header (also sent as X-IMAA-Delivery-Id with the same value) — a unique id for that specific (alert, channel) delivery. Delivery is at-least-once: our worker performs the POST and then records success, so a crash in between causes the delivery to be retried, and the retry carries the same key.

Treat the key as a dedupe token: record the keys you have already processed and ignore a repeat. That way a retried delivery never doubles an action on your side. The key is stable across retries of the same delivery but unique per distinct alert delivery — so two genuinely separate firings of the same rule carry different keys.