Verifying webhook signatures

JKAPay signs every outbound webhook so you can be certain it came from us and wasn't tampered with in transit. Always verify the signature before acting on a webhook. Treat unsigned or invalid webhooks as if they didn't happen.

The signature

Every webhook ships with these headers:

  • X-JKAPay-Signaturev1=<hex digest>
  • X-JKAPay-Timestamp — unix seconds when JKAPay signed
  • X-JKAPay-Key-Id — the pk_… id of the API key whose secret was used

How to verify

  1. Read the raw request body as a string (do NOT re-serialize the parsed JSON — encoding differences will break the hash).
  2. Compute HMAC-SHA256(JKAPAY_WEBHOOK_SECRET, "<timestamp>.<raw body>").
  3. Compare in constant time against the provided digest.
  4. Reject if the timestamp is older than 5 minutes — protects against replay attacks.
Use a constant-time comparison (e.g. crypto.timingSafeEqual) — never === on the hex strings. Naive comparisons leak timing information.

Examples

JavaScript
import crypto from "node:crypto";
import express from "express";

const app = express();

// CRITICAL: get the raw body, NOT the JSON-parsed object.
app.post(
  "/webhooks/jkapay",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["x-jkapay-signature"]; // "v1=..."
    const ts = req.headers["x-jkapay-timestamp"];
    if (!sig || !ts) return res.sendStatus(400);

    // Reject if older than 5 minutes (replay protection)
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
      return res.sendStatus(400);
    }

    const rawBody = req.body.toString("utf8");
    const expected = crypto
      .createHmac("sha256", process.env.JKAPAY_WEBHOOK_SECRET)
      .update(`${ts}.${rawBody}`)
      .digest("hex");

    const provided = sig.slice(3); // strip "v1="
    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(provided, "hex");
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.sendStatus(400);
    }

    const { data } = JSON.parse(rawBody);
    if (data.status === "SUCCESS") {
      // fulfil the order keyed on data.reference
    }
    res.sendStatus(200);
  },
);

Where does the secret come from?

JKAPAY_WEBHOOK_SECRET is your per-key webhook signing secret (format: whsec_…). It is shown once at API-key creation alongside the secret key — store it next to JKAPAY_SECRET_KEY in your environment variables.

Never log it, and rotate it by revoking the API key and issuing a new one. The X-JKAPay-Key-Id header lets you select the right secret if you've issued multiple keys.