Skip to main content

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.

The header

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

1

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

Recompute the signature

Build <t>.<raw_body> and HMAC-SHA256 with your signing secret.
3

Compare in constant time

Use a constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). Don’t use ==.
4

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.