SSoftLinkSMSDocs

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=5f2b0c0e7a9d4c1b8e3f6a2d9c0b1e4f7a8d3c6b9e2f5a1d4c7b0e3f6a9d2c5b
  • t is when we signed the request, as Unix seconds.
  • v1 is 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).

On this page