# Verify webhook signatures in Node and Python

> Every webhook SignWith sends is signed with your endpoint secret. Check the signature before you act on an event, so nobody else can fake one.

Source: https://signwith.co/docs/api/webhooks/verify-signatures · Updated 2026-10-02

## How the signature works

Each request has an `X-SignWith-Signature` header with a Unix timestamp and a signature:

```http
X-SignWith-Signature: t=1790503200,v1=5d41402abc4b2a76b9719d911017c592...
```

To check it:

1. Split the header on `,` and read `t` (the timestamp) and `v1` (the signature).
2. Compute HMAC-SHA256 of the string `"<t>.<raw request body>"`, using the endpoint's signing secret as the key, and hex-encode it. The secret is on the webhook's settings page in SignWith.
3. Compare your result with `v1` using a constant-time comparison.
4. Reject the request if `t` is more than a few minutes old (the examples allow 300 seconds), so an old request can't be replayed.

Use the **raw** request body, exactly as received. If your framework parses the JSON first and you sign the re-serialized object, the bytes differ and the check fails.

## Node.js

The verifier:

```js
const crypto = require('crypto');

function verifySignWithWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((part) => part.trim().split('=', 2))
  );
  const timestamp = Number(parts.t);
  if (!timestamp || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

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

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

In an Express app, read the raw body for this route, not the parsed JSON:

```js
app.post('/webhooks/signwith', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifySignWithWebhook(
    req.body.toString('utf8'),
    req.get('X-SignWith-Signature'),
    process.env.SIGNWITH_WEBHOOK_SECRET
  );
  if (!ok) return res.status(400).send('Invalid signature');

  const event = JSON.parse(req.body);
  // handle event.type ...
  res.sendStatus(200);
});
```

## Python

The verifier:

```python
import hashlib
import hmac
import time


def verify_signwith_webhook(raw_body, signature_header, secret, tolerance_seconds=300):
    parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
    timestamp, received = parts.get("t", ""), parts.get("v1", "")
    if not timestamp.isdigit() or not received:
        return False
    if abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False

    expected = hmac.new(
        secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, received)
```

In a Flask app, `request.get_data()` gives the raw body as bytes:

```python
import os

from flask import Flask, abort, request

app = Flask(__name__)


@app.post("/webhooks/signwith")
def signwith_webhook():
    if not verify_signwith_webhook(
        request.get_data(),
        request.headers.get("X-SignWith-Signature", ""),
        os.environ["SIGNWITH_WEBHOOK_SECRET"],
    ):
        abort(400)

    event = request.get_json()
    # handle event["type"] ...
    return "", 200
```

## Test your verification

Click **Send test event** on the webhook's settings page in SignWith. It sends a `webhook.test` event straight away, signed like every other event. If your endpoint answers `400`, compare the body you sign with the exact bytes received, and check you copied the secret of the right endpoint.

## Common mistakes

- **Signing parsed JSON.** Sign the raw body. Re-serializing changes spacing and key order.
- **A plain `==` comparison.** Use `timingSafeEqual` or `hmac.compare_digest`, which don't leak timing.
- **No timestamp check.** Without it, a captured request can be replayed later.
- **The wrong secret.** Each endpoint has its own signing secret.
