Skip to main content
Every webhook POST that Kibble sends includes an X-Kibble-Signature header. The value is an HMAC-SHA256 digest of the raw request body, computed using the webhook_secret that was returned when you created the invoice. Verifying this signature ensures the request came from Kibble and that the payload was not tampered with in transit.
Always verify the signature before reading or acting on webhook data. Skipping verification makes your endpoint vulnerable to spoofed requests from any party that knows your URL.

How verification works

  1. Read the raw bytes of the incoming request body — before any JSON parsing.
  2. Compute HMAC-SHA256(raw_body, webhook_secret) and encode the result as a lowercase hex string.
  3. Prepend sha256= to form the expected signature.
  4. Compare it to the value in the X-Kibble-Signature header using a constant-time comparison.
If the two values match, the request is authentic. If they differ, reject it with a 401.

Code examples

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyKibbleSignature(
  rawBody: string,
  signatureHeader: string | null,
  webhookSecret: string
): boolean {
  if (!signatureHeader) return false;

  const expected = `sha256=${createHmac("sha256", webhookSecret)
    .update(rawBody)
    .digest("hex")}`;

  // Use constant-time comparison to prevent timing attacks
  try {
    return timingSafeEqual(
      Buffer.from(signatureHeader),
      Buffer.from(expected)
    );
  } catch {
    // Lengths differ — signatures do not match
    return false;
  }
}

// Express.js example — use express.raw() middleware to get the raw body
export async function handleKibbleWebhook(req: Request, res: Response) {
  const rawBody = req.body.toString("utf8");
  const signature = req.headers["x-kibble-signature"] as string | undefined ?? null;
  const secret = process.env.KIBBLE_WEBHOOK_SECRET!;

  if (!verifyKibbleSignature(rawBody, signature, secret)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const payload = JSON.parse(rawBody);
  console.log("Payment confirmed:", payload.invoice_number, payload.status);

  return res.status(200).json({ received: true });
}

Storing the secret

Store the webhook_secret as an environment variable alongside your other credentials. Never hard-code it in source files or commit it to version control.
# .env (server-side only — never expose to the browser)
KIBBLE_WEBHOOK_SECRET=Xk9mLqR3vN8pT2wY...
If you create multiple invoices with webhooks, each invoice has its own webhook_secret. Store them mapped to their invoice_id — for example, in your database next to the invoice record — so you can look up the correct secret when a webhook arrives.

Reading the raw body

Most web frameworks parse the request body automatically, which loses the original bytes needed for verification. You must access the raw, unparsed body to compute the correct digest.
// app/api/webhooks/kibble/route.ts
export async function POST(req: Request) {
  const rawBody = await req.text(); // raw string, not parsed JSON
  // pass rawBody to verifyKibbleSignature(...)
}
If your signature comparison fails unexpectedly, confirm you are reading the raw body as a string (UTF-8). JSON re-serialization changes whitespace and key ordering, producing a different digest.