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.
How the signature works
Each request has an X-SignWith-Signature header with a Unix timestamp and a signature:
X-SignWith-Signature: t=1790503200,v1=5d41402abc4b2a76b9719d911017c592...
To check it:
- Split the header on
,and readt(the timestamp) andv1(the signature). - 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. - Compare your result with
v1using a constant-time comparison. - Reject the request if
tis 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:
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:
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:
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:
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. UsetimingSafeEqualorhmac.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.
Start building
The API and MCP server come with every account. 3 free documents a month, then pay per document.