Skip to main content
Every webhook delivery includes an X-Signature header so you can confirm it came from Etherfuse and wasn’t tampered with.

How It Works

When you create a webhook, the response includes a secret — a base64-encoded HMAC key. Store it securely; it is only returned once. Each delivery signs the payload with that secret:
  1. The JSON body is canonicalized (RFC 8785 JCS — deterministic key ordering, no extra whitespace)
  2. HMAC-SHA256 is computed over the canonicalized string using your secret (decoded from base64)
  3. The result is sent as X-Signature: sha256={hex}

Verifying the Signature

  1. Canonicalize the received JSON body
  2. Decode your webhook secret from base64
  3. Compute HMAC-SHA256 over the canonicalized string
  4. Compare sha256={hex_result} to the X-Signature header using a constant-time comparison
The signature is computed over the canonicalized JSON, not the raw request body. You must canonicalize before comparing or the signature will not match.

Rust

use base64::{engine::general_purpose::STANDARD, Engine};
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::Sha256;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

fn verify_webhook(body: &Value, secret: &str, signature_header: &str) -> bool {
    // serde_jcs canonicalizes per RFC 8785 (cargo add serde_jcs)
    let canonicalized = match serde_jcs::to_string(body) {
        Ok(s) => s,
        Err(_) => return false,
    };
    let key = match STANDARD.decode(secret) {
        Ok(k) => k,
        Err(_) => return false,
    };
    let mut mac = HmacSha256::new_from_slice(&key).expect("HMAC accepts any key length");
    mac.update(canonicalized.as_bytes());
    let digest = hex::encode(mac.finalize().into_bytes());
    let expected = format!("sha256={digest}");

    expected.as_bytes().ct_eq(signature_header.as_bytes()).into()
}

Node.js

import { createHmac, timingSafeEqual } from "crypto";
import canonicalize from "canonicalize"; // npm install canonicalize

function verifyWebhook(body, secret, signatureHeader) {
  const canonicalized = canonicalize(body);
  const key = Buffer.from(secret, "base64");
  const hmac = createHmac("sha256", key).update(canonicalized).digest("hex");
  const expected = `sha256=${hmac}`;

  if (expected.length !== signatureHeader.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

// Usage in an Express handler:
app.post("/webhook", (req, res) => {
  const signature = req.headers["x-signature"];
  if (!verifyWebhook(req.body, process.env.WEBHOOK_SECRET, signature)) {
    return res.status(401).send("Invalid signature");
  }
  // Process the event...
  res.sendStatus(200);
});

Python

import hmac, hashlib, base64
from canonicaljson import encode_canonical_json  # pip install canonicaljson

def verify_webhook(body: dict, secret: str, signature_header: str) -> bool:
    canonicalized = encode_canonical_json(body)
    key = base64.b64decode(secret)
    digest = hmac.new(key, canonicalized, hashlib.sha256).hexdigest()
    expected = f"sha256={digest}"
    return hmac.compare_digest(expected, signature_header)

Delivery & Retries

Failed deliveries (non-2xx responses or connection errors) are retried up to 3 times with 5-second delays between attempts. Return a 2xx promptly to avoid unnecessary retries.