Verifying signatures
Validate every Solwyn webhook with HMAC-SHA256 and a constant-time compare. Recipes for Python and Node.js, plus the pitfalls that fail silently.
Every Solwyn webhook delivery includes an X-Solwyn-Signature header so your
endpoint can verify the request actually came from Solwyn (and was not tampered
with in flight). Verify every request before acting on the payload — an
unverified webhook is just an HTTP request from anyone on the internet.
How the signature is computed
Solwyn computes the signature as:
X-Solwyn-Signature: sha256=<hex>where <hex> is the HMAC-SHA256 of the raw request body bytes, keyed by
your webhook's signing secret:
hex = HMAC-SHA256(signing_secret, raw_body).hexdigest()The signing secret is the value Solwyn returned once when the webhook was created (or last rotated). Treat it like any other API credential — store it in a secret manager, never in source code.
Python (stdlib hmac)
import hmac
import hashlib
def verify(payload: bytes, signature_header: str, secret: str) -> bool:
"""Return True if signature_header matches the HMAC of payload."""
expected = "sha256=" + hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)Wire it into FastAPI by reading the raw body before parsing:
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
WEBHOOK_SECRET = "..." # load from your secret manager
@app.post("/solwyn-webhook")
async def receive(
request: Request,
x_solwyn_signature: str = Header(...),
):
raw_body = await request.body()
if not verify(raw_body, x_solwyn_signature, WEBHOOK_SECRET):
raise HTTPException(status_code=400, detail="invalid signature")
payload = await request.json()
# ... process payload ...
return {"ok": True}hmac.compare_digest is the constant-time string comparison — using ==
leaks information about which byte mismatched and is exploitable as a timing
side channel.
Node.js (stdlib crypto)
const crypto = require("crypto");
function verify(payload, signatureHeader, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(payload).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}Wire it into Express, taking care to apply express.raw() only on the
webhook route so the rest of the app keeps its JSON body parser:
const express = require("express");
const app = express();
const WEBHOOK_SECRET = process.env.SOLWYN_WEBHOOK_SECRET;
app.post(
"/solwyn-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-Solwyn-Signature");
if (!sig || !verify(req.body, sig, WEBHOOK_SECRET)) {
return res.status(400).send("invalid signature");
}
const payload = JSON.parse(req.body.toString("utf8"));
// ... process payload ...
res.json({ ok: true });
},
);
app.use(express.json()); // keep this AFTER the webhook routecrypto.timingSafeEqual requires equal-length buffers. Check the length first
and bail early — that comparison is itself constant-time-safe because it
returns before touching the secret material.
Pitfalls
These mistakes pass local tests but fail in production:
- Verifying re-stringified JSON. Computing HMAC over
JSON.stringify(req.body)is not the same as computing it over the raw bytes Solwyn signed — key ordering, whitespace, and Unicode escaping all differ. Always verify against the raw body. In Express, that meansexpress.raw({ type: "application/json" })on the webhook route. In FastAPI, callawait request.body()beforeawait request.json(). - Letting a global JSON middleware consume the body. If
express.json()runs before your handler,req.bodyis already a parsed object and the original bytes are gone. Mount the raw parser per-route before the global JSON parser. - Using
==instead of a constant-time compare. Plain string equality short-circuits on the first mismatched byte. An attacker who can measure response latency at scale can recover the signature byte by byte. Usehmac.compare_digest(Python) orcrypto.timingSafeEqual(Node). - Header case. The canonical header name is
X-Solwyn-Signature, but most HTTP frameworks normalize header names to lowercase. Read it case-insensitively — Express exposesreq.header("X-Solwyn-Signature")case-insensitively; FastAPI'sHeader()matches bysnake_case. - Comparing only the hex digest. The header value is
sha256=<hex>, not just<hex>. Either compare the full header (recommended — the examples above do this) or strip thesha256=prefix on both sides before comparing.
Rotating the signing secret
Rotation is immediate and atomic: Solwyn derives signing keys from the application secret using HKDF, keyed by webhook ID and a rotation counter. Incrementing the counter invalidates the old key without affecting any other webhook.
There is no overlap window — the next event after rotation is signed with the new key only. To rotate without dropping events:
- Stage the new secret as a second value in your secret store
(e.g.
SOLWYN_WEBHOOK_SECRET_NEW). - Update the verifier to accept either secret as valid.
- Rotate via the dashboard — Solwyn returns the new secret once.
- Replace the staged secret with the rotated one and remove the fallback.