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...| Part | Value |
|---|---|
t | Unix timestamp (seconds) when Kwill signed the payload. |
v1 | HMAC-SHA256 hex digest of "<t>.<raw body>" using your endpoint's signing secret. |
Two other headers are also present on every delivery:
| Header | Value |
|---|---|
X-Kwill-Event-Id | The unique event ID. Use this for deduplication. |
X-Kwill-Event-Type | The event type string (e.g. invoice.paid). |
Verification steps
- Extract
tandv1from theX-Kwill-Signatureheader. - Reject if
|now − t| > 300 seconds(5 minutes). This prevents replay attacks. - Compute
HMAC-SHA256(secret, "<t>.<raw body>")wheresecretis your endpoint's signing secret (thewhsec_…value shown once when you register the webhook). - Compare the computed digest to
v1using a constant-time comparison to prevent timing attacks. - 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.