SOLWYN
Webhooks

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 route

crypto.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 means express.raw({ type: "application/json" }) on the webhook route. In FastAPI, call await request.body() before await request.json().
  • Letting a global JSON middleware consume the body. If express.json() runs before your handler, req.body is 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. Use hmac.compare_digest (Python) or crypto.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 exposes req.header("X-Solwyn-Signature") case-insensitively; FastAPI's Header() matches by snake_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 the sha256= 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:

  1. Stage the new secret as a second value in your secret store (e.g. SOLWYN_WEBHOOK_SECRET_NEW).
  2. Update the verifier to accept either secret as valid.
  3. Rotate via the dashboard — Solwyn returns the new secret once.
  4. Replace the staged secret with the rotated one and remove the fallback.

On this page