Skip to content
SignWithDocs
Esc
  • Developer docs homeDocs
  • ChangelogDocs
  • OverviewREST API · Get started
  • Quick startREST API · Get started
  • Text tagsREST API · Get started
  • AuthenticationREST API · Get started
  • Making requestsREST API · Get started
  • ErrorsREST API · Get started

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.

Updated

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:

JavaScript
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:

JavaScript
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.

Start building

The API and MCP server come with every account. 3 free documents a month, then pay per document.