> ## Documentation Index
> Fetch the complete documentation index at: https://docs.etherfuse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying Webhooks

> How to verify webhook signatures to ensure payloads are authentic

Every webhook delivery includes an `X-Signature` header so you can confirm it came from Etherfuse and nobody changed it.

## How it works

When you [create a webhook subscription](/v2/api-reference/webhook-subscriptions/create-webhook-subscription), the response includes a `secret`: a 32-byte HMAC key encoded as URL-safe base64. **Store it securely; we return it only once.**

We sign each delivery with that secret:

1. We **canonicalize** the JSON body with [RFC 8785 JCS](https://datatracker.ietf.org/doc/html/rfc8785): sorted keys, no extra whitespace.
2. We compute HMAC-SHA256 over the canonicalized string, keyed with your secret decoded from URL-safe base64.
3. We send the result as `X-Signature: sha256={hex}`, with the digest in lowercase hex.

<Note>
  The secret uses the **URL-safe** base64 alphabet (RFC 4648 section 5: `-` and `_` in place of `+` and `/`), with `=` padding. Decode it with a URL-safe decoder. A standard base64 decoder rejects secrets that contain `-` or `_`, or decodes them to the wrong key, and your signatures stop matching.
</Note>

## Verifying the signature

1. **Parse** the received body as JSON and **canonicalize** it.
2. **Decode** your secret from URL-safe base64.
3. **Compute** HMAC-SHA256 over the canonicalized string.
4. **Compare** `sha256={hex_result}` to the `X-Signature` header with a constant-time comparison.

<Warning>
  We compute the signature over the **canonicalized** JSON, not the raw request body. Canonicalize before you compare, or the signature does not match.
</Warning>

### Rust

```rust theme={null}
use base64::{engine::general_purpose::URL_SAFE, 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 URL_SAFE.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

```javascript theme={null}
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, "base64url");
  const hmac = createHmac("sha256", key).update(canonicalized).digest("hex");
  const expected = `sha256=${hmac}`;

  if (typeof signatureHeader !== "string" || expected.length !== signatureHeader.length) {
    return false;
  }
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

// Usage in an Express handler with express.json():
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");
  }
  res.sendStatus(200);
  // Process the event after responding.
});
```

### Python

```python theme={null}
import base64, hashlib, hmac
import rfc8785  # pip install rfc8785

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

<Note>
  Use a library that implements RFC 8785 (JCS). A plain `json.dumps(body, sort_keys=True, separators=(",", ":"))` is close but does not follow every JCS rule.
</Note>

## Delivery and retries

* Return any `2xx` to acknowledge a delivery.
* We make up to **3 attempts** per delivery, **5 seconds** apart. We try again after a connection error, a timeout, a `5xx`, a `408`, a `429`, or any other non-`2xx` status not in the next item.
* We stop after the first response when your endpoint returns `400`, `401`, `403`, `404`, `405`, `410` or `422`. A handler that returns `401` for a bad signature gets no retry.
* We do not send an abandoned delivery later. Read the object with its `GET` endpoint to recover the current state.

See [Webhooks](/v2/webhooks) for the events and their payloads.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.