Webhook Signature Verification
Kaplaix signs every webhook delivery with HMAC-SHA256. Always verify signatures before processing webhook payloads to ensure they originate from Kaplaix and have not been tampered with.
Signature header
Each webhook request includes an x-kaplaix-signature header with this format:
t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Part | Description |
|---|---|
t | Unix timestamp (seconds) when the signature was generated |
v1 | HMAC-SHA256 hex digest |
Verification steps
1. Parse the header
Split the header value by , and extract t (timestamp) and v1 (signature).
2. Build the signed payload
Concatenate the timestamp, a period (.), and the raw request body:
{timestamp}.{rawBody}3. Compute the expected signature
HMAC-SHA256(secret, "{timestamp}.{rawBody}")4. Compare signatures
Use a timing-safe comparison to prevent timing attacks.
5. Check the timestamp
Reject signatures older than 5 minutes to prevent replay attacks.
Implementation examples
TypeScript / Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300,
): boolean {
// 1. Parse header
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => {
const [k, ...v] = p.split("=");
return [k, v.join("=")];
}),
);
const timestamp = parts["t"];
const signature = parts["v1"];
if (!timestamp || !signature) return false;
// 2. Check timestamp (replay protection)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (Math.abs(age) > toleranceSeconds) return false;
// 3. Compute expected signature
const signedPayload = `${timestamp}.${rawBody}`;
const expected = createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// 4. Timing-safe comparison
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}Python
import hashlib
import hmac
import time
def verify_webhook_signature(
raw_body: str,
signature_header: str,
secret: str,
tolerance_seconds: int = 300,
) -> bool:
# 1. Parse header
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return False
# 2. Check timestamp (replay protection)
age = abs(int(time.time()) - int(timestamp))
if age > tolerance_seconds:
return False
# 3. Compute expected signature
signed_payload = f"{timestamp}.{raw_body}"
expected = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
# 4. Timing-safe comparison
return hmac.compare_digest(signature, expected)Security considerations
- Always verify — never process unverified webhooks in production
- Use timing-safe comparison — prevents timing side-channel attacks
- Enforce timestamp tolerance — 5 minutes (300 seconds) is recommended
- Store secrets securely — the webhook secret is shown once at channel creation; treat it like an API key
- Rotate on compromise — use
POST /v1/notification-channels/:id/rotate-secretif the secret is exposed
Last updated on