Kwill

Verifying signatures

How to verify that a webhook delivery came from Kwill and has not been tampered with.

Every webhook delivery includes a signature in the X-Kwill-Signature header. Verifying the signature before processing the payload protects your endpoint from replayed or tampered requests.

Always verify the signature. Skipping this check lets anyone send arbitrary data to your endpoint.


Signature format

X-Kwill-Signature: t=1700000000,v1=a3f9d...
PartValue
tUnix timestamp (seconds) when Kwill signed the payload.
v1HMAC-SHA256 hex digest of "<t>.<raw body>" using your endpoint's signing secret.

Two other headers are also present on every delivery:

HeaderValue
X-Kwill-Event-IdThe unique event ID. Use this for deduplication.
X-Kwill-Event-TypeThe event type string (e.g. invoice.paid).

Verification steps

  1. Extract t and v1 from the X-Kwill-Signature header.
  2. Reject if |now − t| > 300 seconds (5 minutes). This prevents replay attacks.
  3. Compute HMAC-SHA256(secret, "<t>.<raw body>") where secret is your endpoint's signing secret (the whsec_… value shown once when you register the webhook).
  4. Compare the computed digest to v1 using a constant-time comparison to prevent timing attacks.
  5. Reject if they do not match.

Code samples

import crypto from 'node:crypto';

/**
 * Throws if the signature is invalid or the timestamp is stale.
 * @param {string} rawBody   - The raw request body as a string (do NOT parse JSON first)
 * @param {string} sigHeader - The value of the X-Kwill-Signature header
 * @param {string} secret    - Your endpoint's signing secret (whsec_…)
 */
function verifyKwillSignature(rawBody, sigHeader, secret) {
  const TOLERANCE_SECONDS = 300;

  // Parse t=… and v1=… from the header
  const parts = Object.fromEntries(
    sigHeader.split(',').map((p) => { const i = p.indexOf('='); return [p.slice(0, i), p.slice(i + 1)]; })
  );
  const timestamp = parseInt(parts.t, 10);
  const signature = parts.v1;

  if (!timestamp || !signature) {
    throw new Error('Invalid X-Kwill-Signature header format.');
  }

  // Reject stale timestamps
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
    throw new Error('Webhook timestamp is too old — possible replay attack.');
  }

  // Compute expected signature
  const payload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

  // Constant-time comparison
  const expectedBuf = Buffer.from(expected, 'hex');
  const receivedBuf = Buffer.from(signature, 'hex');
  if (
    expectedBuf.length !== receivedBuf.length ||
    !crypto.timingSafeEqual(expectedBuf, receivedBuf)
  ) {
    throw new Error('Webhook signature mismatch.');
  }
}

// Express example
app.post('/webhooks/kwill', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    verifyKwillSignature(
      req.body.toString('utf8'),
      req.headers['x-kwill-signature'],
      process.env.KWILL_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(err.message);
  }

  const event = JSON.parse(req.body);
  // handle event…
  res.sendStatus(200);
});

Your signing secret

The signing secret (whsec_…) is shown once when you create a webhook endpoint. Store it as an environment variable — never hard-code it.

If you lose the secret, delete the endpoint and create a new one; secrets cannot be retrieved after creation.

On this page