Verify signatures
Prove a webhook request came from SoftLinkSMS and wasn't altered or replayed.
Anyone can POST to a public URL. The Softlink-Signature header proves a request came from us:
Softlink-Signature: t=1792396502,v1=5f2b0c0e7a9d4c1b8e3f6a2d9c0b1e4f7a8d3c6b9e2f5a1d4c7b0e3f6a9d2c5btis when we signed the request, as Unix seconds.v1is an HMAC-SHA256, in hex, of the string{t}.{raw request body}, keyed with your endpoint's signing secret.
How to verify
Read the raw request body as bytes, before any JSON parsing. Re-serialising JSON changes the bytes and breaks the signature.
Split the header on
,, then each part on the first =, to get t and v1.Compute
HMAC-SHA256(secret, t + "." + raw_body), hex-encoded. Use the whole secret, including the whsec_ prefix, as the key.Compare it to
v1 with a constant-time comparison.Reject the request if
t is more than 5 minutes from your clock, so a captured request can't be replayed later.import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.SOFTLINK_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = Object.fromEntries(
header.split(',').map((part) => part.split(/=(.*)/s).slice(0, 2)),
);
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
const received = Buffer.from(parts.v1 ?? '', 'hex');
const wanted = Buffer.from(expected, 'hex');
return received.length === wanted.length && crypto.timingSafeEqual(received, wanted);
}
app.post('/webhooks/softlinksms', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('Softlink-Signature') ?? '')) return res.sendStatus(400);
const event = JSON.parse(req.body);
// queue event for processing, keyed by event.id
res.sendStatus(200);
});Rotating the secret
POST /webhook-endpoint/rotate-secret returns a new secret, and every delivery from then on is signed with it. To rotate without dropping events, have your verifier accept either secret for a few minutes: deploy the new secret alongside the old one, rotate, then remove the old one.
Common mistakes
The usual causes of failed verification: parsing the body as JSON before verifying (verify the raw bytes), leaving out the whsec_ prefix from the key, using a non-constant-time comparison, or a server clock that's drifted by more than 5 minutes (use NTP).