> For the full page index, fetch https://echonova.ai/llms.txt

# Verify a webhook signature

Recompute the HMAC-SHA256 signature on every delivery, compare it in constant time, and reject requests that are stale or unsigned.

Anyone who knows your endpoint URL can send it JSON. The signature on each
delivery proves the request came from EchoNova and that the body was not
changed on the way. Verify it before you act on the event.

## What you need

- A webhook and its signing secret, the `whsec_` string returned once at
  creation. It is not shown again, so save it when you create the webhook;
  see
  [Save the signing secret](/docs/developers/webhooks-configure#step-3-save-the-signing-secret).
- Access to the raw request body on your server. Frameworks that parse JSON
  before your code runs must be told to keep the original bytes.
- The three headers on every delivery, listed on
  [Webhook events](/docs/developers/webhooks-events).

## Steps

### Step 1: Read the raw body and the headers

Take the request body exactly as it arrived, as bytes. Do not parse it and
serialise it again: any change in key order, spacing or Unicode escaping
produces a different signature. Read `X-Webhook-Timestamp` and
`X-Webhook-Signature` from the headers.

**Recommended**

```javascript
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body; // Buffer, byte for byte as it arrived
});
```

**Less effective**

```javascript
app.post('/webhook', express.json(), (req, res) => {
  const rawBody = JSON.stringify(req.body); // re-serialised; the signature no longer matches
});
```

### Step 2: Build the signed string

Join the timestamp and the body with a single dot:

```text
<X-Webhook-Timestamp>.<raw body>
```

The timestamp is the decimal string from the header. The body is the raw
bytes. Nothing else is included: not the event name, not the URL, not the
other headers.

### Step 3: Compute the expected signature

Compute HMAC-SHA256 over the signed string, keyed with the full signing
secret including its `whsec_` prefix, and encode the result as lowercase hex.
`X-Webhook-Signature` carries the same 64-character hex string.

### Step 4: Compare in constant time

Compare your result with the header using a constant-time function:
`crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python. A plain
string comparison returns early at the first differing byte, and that timing
difference can leak the secret to a patient attacker. Reject the request with
a `401` when the values differ or the header is missing.

### Step 5: Reject stale timestamps

Reject a delivery whose `X-Webhook-Timestamp` is more than 300 seconds
away from your server's clock, in either direction. The window is yours to
choose; 300 seconds is the recommended value. A signed request that an
attacker captured can otherwise be replayed later. Every retry carries a
fresh timestamp and a fresh signature, and the third and last attempt starts
at most about 35 seconds after the first, so a 5-minute window does not
reject legitimate retries. Keep your server's clock synchronised with NTP.

### Step 6: Acknowledge, then do the work

Return a `2xx` status as soon as the signature checks out, then process the
event. EchoNova waits 10 seconds for the response and counts anything else as
a failed attempt.

:::tabs

```python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["ECHONOVA_WEBHOOK_SECRET"].encode()  # whsec_...
MAX_AGE_SECONDS = 300  # recommended window; the choice is yours


def verify(raw_body: bytes, headers) -> bool:
    signature = headers.get("X-Webhook-Signature", "")
    timestamp = headers.get("X-Webhook-Timestamp", "")
    if not timestamp.isdigit():
        return False
    if abs(int(time.time()) - int(timestamp)) > MAX_AGE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), signature.encode())


@app.post("/webhook")
def webhook():
    if not verify(request.get_data(), request.headers):
        abort(401)
    event = request.headers["X-Webhook-Event"]
    payload = request.get_json(force=True)
    # Hand the event to a queue here, then answer.
    return "", 200
```

```javascript
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.ECHONOVA_WEBHOOK_SECRET; // whsec_...
const MAX_AGE_SECONDS = 300; // recommended window; the choice is yours

function verify(rawBody, headers) {
  const signature = headers['x-webhook-signature'] ?? '';
  const timestamp = headers['x-webhook-timestamp'] ?? '';
  if (!/^\d+$/.test(timestamp)) return false;
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (Math.abs(age) > MAX_AGE_SECONDS) return false;
  const expected = createHmac('sha256', SECRET).update(`${timestamp}.`).update(rawBody).digest();
  const received = Buffer.from(signature, 'hex');
  if (received.length !== expected.length) return false;
  return timingSafeEqual(expected, received);
}

createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks);
    if (!verify(rawBody, req.headers)) {
      res.writeHead(401).end();
      return;
    }
    const event = req.headers['x-webhook-event'];
    const payload = JSON.parse(rawBody.toString('utf8'));
    // Hand the event to a queue here, then answer.
    res.writeHead(200).end();
  });
}).listen(3000);
```

:::

## What happens next

- A request that passes is a genuine EchoNova delivery with an unmodified
  body. Read `X-Webhook-Event` to pick the handler, then parse the JSON.
- A request that fails gets `401` and no further processing. A non-`2xx`
  response is a failed attempt and is retried, so a signature that never
  matches ends as a failed delivery, and 5 failed deliveries in a row
  disable the webhook. If that happens, fix the secret and enable the
  webhook again.

## Limits

| Limit             | Value                                        | Applies to            |
| ----------------- | -------------------------------------------- | --------------------- |
| Signature         | HMAC-SHA256, 64 lowercase hex characters     | `X-Webhook-Signature` |
| Timestamp         | Unix time in whole seconds                   | `X-Webhook-Timestamp` |
| Signing key       | The full secret, `whsec_` plus 43 characters | HMAC key              |
| Response deadline | 10 seconds per attempt                       | Your endpoint         |
| Stale window      | 300 seconds, recommended                     | Your verifier         |

## Troubleshooting

### The signature never matches

The most common causes, in order: your framework parsed and re-serialised the
body before you hashed it; you hashed the body without the `<timestamp>.`
prefix; you used the 12-character prefix instead of the full secret; the
secret belongs to a different webhook. Log the raw bytes you hashed and the
timestamp you used, and compare their lengths with what arrived.

### Every request is rejected as stale

Your server's clock is off. Check it against NTP. If your handler runs
behind a queue, verify the signature at the edge, before queuing, so the
check uses the arrival time.

### The retry has a different signature

Expected. Each attempt is signed at the moment it is sent, with a new
timestamp. The body is identical across attempts, so you can still
de-duplicate on it.

### My framework only gives me parsed JSON

Configure it to keep the raw body for the webhook route. In Express, mount
`express.raw({ type: 'application/json' })` on that route before any JSON
parser. In Flask, `request.get_data()` returns the raw bytes as long as you
call it before `request.get_json()`.

## FAQ

### Which secret do I use?

The full `signingSecret` returned when the webhook was created, including
the `whsec_` prefix. The 12-character `signingSecretPrefix` is a label, not a
key.

### Does the event name take part in the signature?

No. Only the timestamp and the body are signed. Read the event name from
`X-Webhook-Event` and, if you need to, check it against the fields in the
body.

### Can I verify with cURL?

No. cURL cannot compute an HMAC. Use the Python or Node sample above, or any
language with an HMAC-SHA256 function.

### Can I rotate the secret?

The secret is fixed for the life of the webhook. Create a second webhook
for the same URL and events, deploy its secret, then delete the first one.
During the overlap your endpoint must accept either secret.

### What if I do not verify?

Anyone who finds your URL can post a fake `job.completed` with a link of
their choosing, or a fake `spending.exhausted` that makes you disable a key.
Treat an unverified delivery as untrusted input.

## Related

- [Configure a webhook](/docs/developers/webhooks-configure)
- [Webhook events](/docs/developers/webhooks-events)
- [Delivery, retries and failures](/docs/developers/webhooks-retries)
