Signatures
Every webhook request carries these headers:
Content-Type: application/jsonUser-Agent: kasskeep-webhook/1Kasskeep-Signature: t=1767225600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdKasskeep-Event-Id: evt_...Kasskeep-Event-Type: purchaseScheme
Section titled “Scheme”v1 = hex( HMAC-SHA256( webhook_secret, t + "." + raw_body ) )tis the Unix time in seconds when the request was signed.raw_bodyis the exact bytes of the request body. Do not parse and re-serialize JSON before verifying.- The key is the whole
webhook_secretstring, including thewhsec_prefix, as UTF-8. - Reject requests where
tis more than 300 seconds from your clock. - Compare in constant time.
The same scheme signs ad reward events and test events.
Node.js
Section titled “Node.js”import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyKasskeepSignature(secret, header, rawBody, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest(); const actual = Buffer.from(parts.v1, "hex"); return actual.length === expected.length && timingSafeEqual(actual, expected);}rawBody can be a Buffer or a string. With Express use express.raw({ type: "application/json" }) on the webhook route so the body is not parsed first.
Test vectors
Section titled “Test vectors”All vectors use this secret:
whsec_kasskeep_test_vector_secret_0001Verify them with the tolerance check turned off, since the timestamps are in the past.
1. A test event
t = 1767225600body = {"id":"evt_00000000000000000000000000000001","type":"test","created_at":"2026-01-01T00:00:00.000Z","app_user_id":null,"platform":null,"environment":null,"product_id":null,"transaction_id":null,"original_transaction_id":null,"purchased_at":null,"expires_at":null,"price_micros":null,"currency":null,"ownership":null,"data":{"test":true}}Kasskeep-Signature: t=1767225600,v1=28b67105363ce706a96273806aead08f023d7ee0943f554d24c23092a285a0982. Non-ASCII characters in the body (the body is hashed as UTF-8 bytes)
t = 1767225600body = {"id":"evt_3f0c","type":"purchase","price_micros":9990000,"currency":"USD","data":{"note":"café ✓"}}Kasskeep-Signature: t=1767225600,v1=0f8440805bf6acbead256925900012c74620ca8cadc4476de63a13ba2cb200153. Empty body
t = 1767225601body = (empty)Kasskeep-Signature: t=1767225601,v1=c926a366d97dd550371b5547522a89745af722d51d02bc155fb9f048de48f81b4. Must fail: vector 1’s header with any byte of the body changed, or with t changed to 1767225601.
These vectors were produced by the same signing function Kasskeep uses to deliver webhooks.