RSA signature verification for Kick.com webhooks (Kick Events API), following Kick's official webhook security spec.
Zero runtime dependencies. TypeScript-first, ships its own types.
There's no dedicated Node.js/TypeScript package for this today — general-purpose Kick API clients exist (@nekiro/kick-api, @retconned/kick-js), but not a focused signature-verification module like the ones that already exist for Go and Python.
npm install kick-webhook-verifyKick signs the exact bytes of the request body. If your framework parses JSON before you verify (e.g. express.json()), the body gets re-serialized and the signature will no longer match — even though the payload is semantically identical. Always capture the raw body.
import express from "express";
import { verifyKickWebhook } from "kick-webhook-verify";
const app = express();
app.post(
"/webhooks/kick",
express.raw({ type: "application/json" }), // raw body, not parsed JSON
async (req, res) => {
const result = await verifyKickWebhook({
publicKey: process.env.KICK_PUBLIC_KEY!,
headers: {
"kick-event-message-id": req.header("Kick-Event-Message-Id"),
"kick-event-message-timestamp": req.header("Kick-Event-Message-Timestamp"),
"kick-event-signature": req.header("Kick-Event-Signature"),
"kick-event-type": req.header("Kick-Event-Type"),
},
rawBody: req.body, // Buffer, from express.raw()
});
if (!result.isValid) {
return res.status(401).json({ error: "Invalid signature", details: result.errors });
}
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event
res.sendStatus(200);
}
);Kick publishes its current RSA public key at a public endpoint. fetchKickPublicKey() wraps that call with retry + timeout:
import { fetchKickPublicKey } from "kick-webhook-verify";
const publicKey = await fetchKickPublicKey();
// cache it yourself (Redis, in-memory, env var, whatever fits your app) —
// this function does not cache anything.If you're verifying many webhooks and want to reuse a configured instance instead of passing publicKey every time:
import { KickSecurityVerifier, createDefaultSecurityConfig } from "kick-webhook-verify";
const verifier = new KickSecurityVerifier(
createDefaultSecurityConfig(publicKey, { maxTimestampDelta: 300 })
);
const result = await verifier.verifyWebhookAuthenticity(headers, rawBody);Pass a logger (anything with debug/info/warn/error(message, meta?), e.g. console or winston) if you want verification internals logged — it's silent by default.
verifyKickWebhook({ publicKey, headers, rawBody, maxTimestampDelta?, logger? })→Promise<VerificationResult>fetchKickPublicKey({ maxAttempts?, timeoutMs?, logger? })→Promise<string>—maxAttemptsis the total number of tries (default 3), not additional retries. A malformed 200 response is not retried (retrying won't fix a response Kick already sent successfully).new KickSecurityVerifier(config)—verifyWebhookAuthenticity,updateConfig,getConfigStatus,generateSHA256HashcreateDefaultSecurityConfig(publicKey?, overrides?)parseKickPublicKey(pem)— validates PEM formattingKICK_SECURITY_CONSTANTS—MAX_TIMESTAMP_DELTA,SIGNATURE_ALGORITHM,RSA_PADDING
VerificationResult:
interface VerificationResult {
isValid: boolean;
errors: string[];
messageId: string;
timestamp: string;
}- Signed message format:
messageId + "." + timestamp + "." + rawBody, verified withRSA-SHA256/ PKCS1v15 padding — exactly as Kick documents it. - Timestamps outside
maxTimestampDelta(default 300s) are rejected to mitigate replay attacks. getConfigStatus()never exposes the raw public key, only a truncated fingerprint — safe to log or expose in health checks.
MIT
Extracted from and maintained alongside the webhook handling in TriBathon, a multi-platform subathon timer for Twitch, Kick, YouTube, StreamElements and Streamlabs.