Verifying Signatures
Every outbound webhook from kirim.dev 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.
Header format
Section titled “Header format”X-Kirim-Signature: t=1716480000,v1=<hex>[,v1=<hex>...]| Component | Meaning |
|---|---|
t=… | Unix epoch seconds (UTC) when kirim.dev signed the payload. |
v1=… | Hex-encoded HMAC-SHA256 of "{t}.{raw_body}" keyed by one of your subscription’s active signing secrets. |
Multiple v1= segments are comma-separated and appear during secret
rotation — kirim.dev signs each delivery with every currently
active secret so you can verify against either while you roll your
stored secret over.
What gets signed
Section titled “What gets signed”The signing payload is "{t}.{raw_body}" — literally the unix
timestamp, a single dot, then the raw bytes of the request body as
kirim.dev sent them (no pretty-printing, no key reordering).
Verification recipe
Section titled “Verification recipe”The official SDK ships a verifier that runs on Node, Bun, Deno, and any Web-Crypto-capable edge runtime.
import { verify } from '@kirimdev/sdk/webhooks'
app.post('/webhooks/kirim', async (req, res) => { const rawBody = req.body.toString('utf8') // express.raw() — bytes preserved
const ok = await verify({ rawBody, signatureHeader: req.headers['x-kirim-signature'], secrets: [ process.env.KIRIM_SECRET_PRIMARY!, process.env.KIRIM_SECRET_ROTATING ?? null, ].filter(Boolean) as string[], toleranceSeconds: 300, // optional; defaults to 5 min })
if (!ok) return res.status(401).send('invalid signature')
const event = JSON.parse(rawBody) // ... process event res.status(200).send('ok')})verify accepts an array of secrets so you don’t have to write
rotation logic yourself — pass every active secret, it returns
true if any of them produces a matching signature.
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 = header.split(',').map((p) => p.trim()) const tPart = parts.find((p) => p.startsWith('t='))?.slice(2) const v1s = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3))
const t = Number(tPart) if (!t || v1s.length === 0) return false
// Replay protection — reject signatures older than 5 minutes. if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const signed = `${t}.${rawBody}`
// Verify against EVERY active secret. 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 hmacimport hashlibimport time
def verify_kirim_signature( raw_body: bytes, header: str | None, secrets: list[str], tolerance_seconds: int = 300,) -> bool: if not header: return False
parts: dict[str, list[str]] = {} for p in header.split(","): k, _, v = p.strip().partition("=") parts.setdefault(k, []).append(v)
try: t = int(parts["t"][0]) except (KeyError, IndexError, ValueError): return False
v1s = parts.get("v1", []) if not v1s: return False
if abs(time.time() - t) > tolerance_seconds: return False # replay window exceeded
signed = f"{t}.".encode() + raw_body
for secret in secrets: expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() for received in v1s: if hmac.compare_digest(expected, received): return True return Falserequire "openssl"
def verify_kirim_signature(raw_body, header, secrets, tolerance_seconds: 300) return false if header.nil?
parts = { t: nil, v1: [] } header.split(",").each do |p| k, v = p.strip.split("=", 2) case k when "t" then parts[:t] = v.to_i when "v1" then parts[:v1] << v end end return false unless parts[:t] && !parts[:v1].empty? return false if (Time.now.to_i - parts[:t]).abs > tolerance_seconds
signed = "#{parts[:t]}.#{raw_body}" secrets.any? do |secret| expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed) parts[:v1].any? { |received| OpenSSL.fixed_length_secure_compare(expected, received) } endendpackage kirimwebhook
import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "math" "strconv" "strings" "time")
func Verify( rawBody []byte, header string, secrets []string, toleranceSeconds float64,) bool { if header == "" { return false }
var t int64 var v1s []string for _, part := range strings.Split(header, ",") { kv := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(kv) != 2 { continue } switch kv[0] { case "t": t, _ = strconv.ParseInt(kv[1], 10, 64) case "v1": v1s = append(v1s, kv[1]) } } if t == 0 || len(v1s) == 0 { return false } if math.Abs(float64(time.Now().Unix()-t)) > toleranceSeconds { return false }
signed := []byte(strconv.FormatInt(t, 10) + ".") signed = append(signed, rawBody...)
for _, secret := range secrets { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(signed) expected := []byte(hex.EncodeToString(mac.Sum(nil))) for _, received := range v1s { if subtle.ConstantTimeCompare(expected, []byte(received)) == 1 { return true } } } return false}Replay protection
Section titled “Replay protection”The recipes above reject signatures with a t= more than 5 minutes
away from now. This guards against an attacker who captures a signed
delivery (e.g. from a proxy log) and replays it weeks later — the
timestamp won’t validate.
If your endpoint runs geographically far from kirim.dev’s region,
increase toleranceSeconds to absorb clock drift, but keep it under
10 minutes.
Rotating signing secrets
Section titled “Rotating signing secrets”Subscriptions support multiple active signing secrets simultaneously. Rotate at any time:
-
Mint a new secret. kirim.dev returns the plaintext once — store it immediately.
Terminal window curl -X POST \https://api.kirim.chat/v1/webhook_subscriptions/wbs_…/secrets \-H "Authorization: Bearer $KIRIM_KEY"const next = await kirim.webhookSubscriptions.addSecret('wbs_…')// next.secret is plaintext — persist it now. -
Deploy your verifier with both secrets in the
secretsarray. For the duration of the overlap, kirim.dev signs every delivery with both secrets (twov1=segments per header). Either one must verify.secrets: [OLD_SECRET, NEW_SECRET].filter(Boolean) -
Confirm in production that deliveries verify under the new secret — the dashboard’s Recent deliveries view shows which secret matched each request.
-
Revoke the old secret.
Terminal window curl -X DELETE \https://api.kirim.chat/v1/webhook_subscriptions/wbs_…/secrets/whs_OLD \-H "Authorization: Bearer $KIRIM_KEY"Future deliveries are signed only with the new secret. Remove the old one from your environment.
Common pitfalls
Section titled “Common pitfalls”What’s next
Section titled “What’s next”- Event catalogue — every event type and what triggers it.
- Retries & auto-disable — what happens when your endpoint returns non-2xx.
- Payload examples — copy-paste fixtures for each event type.