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-Signature—v1=<hex digest>X-JKAPay-Timestamp— unix seconds when JKAPay signedX-JKAPay-Key-Id— thepk_…id of the API key whose secret was used
How to verify
- Read the raw request body as a string (do NOT re-serialize the parsed JSON — encoding differences will break the hash).
- Compute
HMAC-SHA256(JKAPAY_WEBHOOK_SECRET, "<timestamp>.<raw body>"). - Compare in constant time against the provided digest.
- 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.