Skip to content
Webhooks

Verifying Signatures

Every outbound webhook from Kirim carries an HMAC signature in the X-Kirim-Signature header. You MUST verify it — without verification, anyone who learns your endpoint URL can fire fake events at you.

X-Kirim-Signature: t=1716480000,v1=<hex>[,v1=<hex>...]
ComponentMeaning
t=…Unix epoch seconds when Kirim signed the payload.
v1=…Hex-encoded HMAC-SHA256 of "{t}.{raw_body}" keyed by your subscription secret.

Multiple v1= segments appear during secret rotation — Kirim signs with every active secret simultaneously, so you can verify against either while you roll your stored secret over.

The signed payload is "{t}.{raw_body}" — literal dot, no separator spaces, raw bytes of the body as received (not pretty-printed).

import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyKirimSignature(
rawBody: string,
header: string | undefined,
secrets: string[],
toleranceSeconds = 300,
): boolean {
if (!header) return false
const parts = Object.fromEntries(
header.split(',').map((p) => {
const [k, ...v] = p.trim().split('=')
return [k, v.join('=')]
}),
) as { t?: string; v1?: string }
// Header may have multiple v1= — re-split to collect all.
const v1s = header
.split(',')
.map((p) => p.trim())
.filter((p) => p.startsWith('v1='))
.map((p) => p.slice(3))
const t = Number(parts.t)
if (!t || v1s.length === 0) return false
// Replay protection — reject signatures older than 5 minutes.
const ageSec = Math.abs(Date.now() / 1000 - t)
if (ageSec > toleranceSeconds) return false
const signed = `${t}.${rawBody}`
// Verify against ALL active secrets. Pass if ANY match — secret
// rotation requires accepting both old and new during the overlap.
return secrets.some((secret) => {
const expected = createHmac('sha256', secret).update(signed).digest('hex')
return v1s.some((received) => {
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(received, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
})
})
}
import express from 'express'
import { verifyKirimSignature } from './kirim.js'
const app = express()
app.post(
'/kirim-webhook',
express.raw({ type: 'application/json' }), // raw bytes preserved
(req, res) => {
const ok = verifyKirimSignature(
req.body.toString('utf8'),
req.headers['x-kirim-signature'] as string,
[process.env.KIRIM_SECRET_PRIMARY!, process.env.KIRIM_SECRET_ROTATING!].filter(Boolean),
)
if (!ok) return res.status(401).send('invalid signature')
const payload = JSON.parse(req.body.toString('utf8'))
// ... process event
res.status(200).send('ok')
},
)

When you rotate a secret via the dashboard or API, Kirim:

  1. Generates a new secret. Returns the plaintext once.
  2. For the duration of the overlap window (default 72 hours), signs every outbound delivery with both secrets — two v1= segments in the header.
  3. After the overlap or on explicit revoke, signs with only the new one.

Roll your stored secret over inside that 72-hour window:

  1. Store the new secret alongside the old one.
  2. Verify against both (the recipe above does this).
  3. Once you’ve confirmed the new secret works, remove the old one from storage.
  4. Revoke the old secret via the dashboard or DELETE /v1/webhook_subscriptions/{id}/secrets/{secret_id}.

The recipe rejects signatures older than 5 minutes. This guards against an attacker who captures a signed delivery and replays it weeks later — the timestamp t won’t validate.

If your endpoint is geographically far from Kirim’s region, increase toleranceSeconds to account for clock drift, but keep it under 10 minutes.

  • Don’t roll your own HMAC compare. Always use the language’s timing-safe compare (crypto.timingSafeEqual, hmac.compare_digest, OpenSSL.fixed_length_secure_compare, hmac.Equal).
  • Don’t skip verification because “the URL is secret”. URLs leak — via DNS logs, browser history, support tickets, accidental screenshots, vendor breaches.
  • Don’t verify after parsing JSON. As above — verify raw bytes first, parse second.
  • Don’t ignore the timestamp. A signature without freshness check is a replay-attack invitation.