Webhook signing
Every webhook event Kudos CRM ingests must be signed by the producer. Kudos CRM rejects unsigned, stale, or mis-signed requests with 401 Unauthorized and the request never reaches your tenant's data.
The scheme is intentionally identical to Stripe's webhook signing scheme. If you have a Stripe verification helper, the only change needed is the secret-storage path and the header name (X-Kudos-Signature instead of Stripe-Signature).
Algorithm
signing_string = "<unix_timestamp>.<raw_request_body_bytes>"
signature = lowercase_hex( HMAC_SHA256( signing_secret, signing_string ) )
header = "t=<unix_timestamp>,v1=<signature>"
Three invariants make verification correct:
- Raw bytes only. The HMAC is computed over the bytes you actually transmit, before any parsing or re-serialisation. Pretty-printing the JSON, re-ordering keys, or adding whitespace breaks verification on the receiver.
- Unix seconds.
tis seconds since epoch in UTC. Both sides keep clocks within ±60 s via NTP. - Constant-time compare. Receivers use
OpenSSL.secure_compare(or your platform's equivalent). Never==. Kudos CRM enforces this on its side and we encourage you to do the same when verifying our outgoing reverse-channel signatures.
Header parsing
The header is a list of comma-separated k=v fragments. Producers must emit t= exactly once and may emit one or more v1= signatures (one per active secret during rotation):
X-Kudos-Signature: t=1735394400,v1=4d2b…ab1c
X-Kudos-Signature: t=1735394400,v1=4d2b…ab1c,v1=7d8e…f30a # during rotation
Receivers must:
- Reject if
t=is missing. - Reject if no
v1=is present. - Reject if
|now - t| > 300seconds (the skew window, 5 minutes). - Accept if any
v1=matches any active configured secret (per-source rotation overlap up to two secrets).
Verification — five languages
import crypto from "node:crypto";
const MAX_SKEW_SECONDS = 300;
export function verifyKudosSignature({ header, body, secrets, now = Math.floor(Date.now() / 1000) }) {
if (!header) return false;
let ts = null;
const sigs = [];
for (const part of header.split(",")) {
const [k, v] = part.trim().split("=");
if (k === "t") ts = parseInt(v, 10);
else if (k === "v1") sigs.push(v);
}
if (ts === null || sigs.length === 0) return false;
if (Math.abs(now - ts) > MAX_SKEW_SECONDS) return false;
const message = Buffer.from(`${ts}.${body}`);
for (const secret of secrets) {
const expected = crypto.createHmac("sha256", secret).update(message).digest("hex");
for (const provided of sigs) {
if (provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) {
return true;
}
}
}
return false;
}
Wiring it into your producer
- Capture raw body once
Capture the request body bytes before any parsing middleware sees them. In Express, set
app.use(express.raw({ type: "application/json" }))on the route. In Rails, readrequest.raw_post. In Flask,request.get_data(). - Sign once per delivery attempt
Always recompute the timestamp + signature per attempt. Never cache a signed header — replays older than the skew window will be rejected.
- Use a constant-time comparator
crypto.timingSafeEqual/hmac.compare_digest/OpenSSL.secure_compare/hmac.Equal/hash_equals. The standard libraries each ship one. Use it.
Rotation overlap
Kudos CRM allows up to two active signing secrets per source concurrently. To rotate without dropped deliveries:
- An operator clicks Rotate signing secret in the CRM console. A second active secret is minted; the old secret stays valid.
- The operator pastes the new secret into your producer config alongside the old one and starts emitting both signatures (multi-
v1=). - Once you confirm both signatures are in-flight and accepted, the operator revokes the old secret.
This is the same pattern Stripe uses for endpoint-secret rotation. The receiver-side accept-any-active-secret rule is what makes the cutover invisible to your traffic.
Common signing failures
| Symptom | Likely cause |
|---|---|
| Verification fails on first delivery | JSON re-serialised between sign and send. Use the same byte buffer for both. |
| Verification fails after 30 s | Receiver clock skew. Both sides must run NTP. |
| Verification fails after rotation | Old secret revoked before producer started emitting the new signature. |
| Verification fails intermittently | Producer is using == instead of constant-time compare; a timing oracle is leaking. (Affects security, not correctness — fix anyway.) |
Move on to the event catalog to pick which events you will send.