Documentation Index
Fetch the complete documentation index at: https://docs.kayle.id/llms.txt
Use this file to discover all available pages before exploring further.
Every webhook delivery carries an X-Kayle-Signature header. Verify it before processing the body. An attacker who knows your endpoint URL but not the signing secret cannot forge a valid signature.
X-Kayle-Signature: t=1714914000,v1=8a7b...
X-Kayle-Delivery-Id: whd_...
X-Kayle-Event: verification.attempt.succeeded
Content-Type: application/json
t is a Unix timestamp (seconds). v1 is HMAC-SHA256(t + "." + raw_body, signing_secret) rendered as lowercase hex.
The verification algorithm
Capture the raw body
Hash exactly the bytes Kayle sent. If your framework parses JSON before you can read the raw body, capture the buffer first — re-serialising parsed JSON does not produce the same bytes.
Recompute the signature
Build <t>.<raw_body> and HMAC-SHA256 with your signing secret.
Compare in constant time
Use a constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). Don’t use ==.
Check the timestamp
Reject deliveries where |now - t| is more than five minutes. This prevents replay attacks if the signing secret leaks transiently.
If any step fails, return 400 and don’t process the body. Kayle treats non-2xx responses as delivery failures and retries.
Code samples
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/kayle",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.header("X-Kayle-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=")),
) as { t?: string; v1?: string };
if (!parts.t || !parts.v1) return res.sendStatus(400);
const timestamp = Number.parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.sendStatus(400);
}
const expected = crypto
.createHmac("sha256", process.env.KAYLE_SIGNING_SECRET!)
.update(`${parts.t}.${req.body.toString("utf8")}`)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex"),
);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// process `event` here
res.sendStatus(200);
},
);
Test signature verification locally by calling your endpoint with a delivery replayed from the dashboard or via POST /v1/webhooks/events/:event_id/replay. The signature is regenerated on each delivery, so a stale capture won’t pass.
Where the secret comes from
The signing secret is returned exactly once when you create an endpoint, and again on rotation. It can be revealed by an owner-role user via POST /v1/webhooks/endpoints/:endpoint_id/signing-secret/reveal. Store it as you would any high-value credential.