Verify signatures
Prove a webhook really came from Swop before you ship anything.
Anyone can POST to your webhook URL. The Swop-Signature header proves a request came from Swop and wasn't changed on the way. Always verify it before acting on an event.
How the signature works
Swop-Signature: t=1761760800,v1=5f2b8c…e41c9| Part | Meaning |
|---|---|
t | When Swop signed it, in Unix seconds |
v1 | Hex-encoded HMAC-SHA256 of the string "<t>.<raw request body>", keyed with your whsec_ signing secret |
To verify:
- Read the raw request body as bytes. Don't parse and re-serialize the JSON first; any whitespace change breaks the signature.
- Split the header into
tandv1. - Reject it if
tis more than 5 minutes from now. That stops someone replaying an old request. - Compute HMAC-SHA256 of
t + "." + rawBodywith your secret, hex-encode it, and compare it tov1in constant time.
Verifier code
These verifiers match Swop's own signing code and were tested against it.
// Node 18+ with Express. express.raw() keeps the exact bytes Swop signed.
import crypto from 'node:crypto';
import express from 'express';
export function verifySwopSignature(secret, header, rawBody, toleranceS = 300) {
const parts = Object.fromEntries(
String(header || '').split(',').map((kv) => {
const i = kv.indexOf('=');
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = String(parts.v1 || '');
if (given.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
const app = express();
app.post('/webhooks/swop', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifySwopSignature(process.env.SWOP_WEBHOOK_SECRET, req.get('Swop-Signature'), raw)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(raw);
switch (event.event) {
case 'checkout.paid': /* fulfil event.data.orderId */ break;
case 'payout.released': /* book revenue for event.data.orderId */ break;
case 'ping': /* "Send test" from the Developer page */ break;
}
res.sendStatus(200); // any 2xx counts as delivered
});
app.listen(3000);Common mistakes
| Symptom | Cause |
|---|---|
| Every signature fails | Your framework parsed the JSON before you read it. Use the raw body (express.raw, request.get_data()). |
| Fails only in production | Wrong secret: each webhook has its own whsec_, and re-adding a webhook issues a new one. |
| Fails intermittently | Your server clock is off by more than 5 minutes. Sync it with NTP. |
| Works locally, not deployed | A proxy is rewriting the body or stripping the Swop-Signature header. |