Skip to main content

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:

  1. 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.
  2. Unix seconds. t is seconds since epoch in UTC. Both sides keep clocks within ±60 s via NTP.
  3. 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| > 300 seconds (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​

  1. 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, read request.raw_post. In Flask, request.get_data().

  2. 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.

  3. 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:

  1. An operator clicks Rotate signing secret in the CRM console. A second active secret is minted; the old secret stays valid.
  2. The operator pastes the new secret into your producer config alongside the old one and starts emitting both signatures (multi-v1=).
  3. 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​

SymptomLikely cause
Verification fails on first deliveryJSON re-serialised between sign and send. Use the same byte buffer for both.
Verification fails after 30 sReceiver clock skew. Both sides must run NTP.
Verification fails after rotationOld secret revoked before producer started emitting the new signature.
Verification fails intermittentlyProducer 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.