Verify webhook signatures
Anyone who knows your webhook URL could post fake events to it. Each real KiliChat request is signed with your webhook's signing secret (whsec_…), so your endpoint can check it before trusting it.
How is the signature made?
- KiliChat takes the timestamp from the
X-KiliChat-Timestampheader (Unix seconds). - It joins the timestamp, a full stop and the raw request body:
<timestamp>.<body>. - It computes an HMAC-SHA256 of that string using your signing secret as the key, written as lowercase hex.
- It sends
sha256=<hex>in theX-KiliChat-Signatureheader.
How do I check it?
- Read the raw body exactly as received, before any JSON parsing.
- Compute the same HMAC with your secret.
- Compare it with
X-KiliChat-Signatureusing a constant-time comparison. - Reject the request if the timestamp is more than 5 minutes away from your server's clock. This stops someone replaying an old request.
- Answer
401if anything doesn't match.
Use the raw body
Parsing the JSON and turning it back into text can change spacing or key order, and the signature won't match. Most frameworks have a way to get the raw body, like express.raw() in Express.
Node.js with Express
js
const express = require("express");
const crypto = require("crypto");
const app = express();
const SECRET = process.env.KILICHAT_WEBHOOK_SECRET; // whsec_...
app.post("/kilichat-webhook", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-KiliChat-Timestamp") || "";
const given = req.get("X-KiliChat-Signature") || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(timestamp + "." + req.body.toString()).digest("hex");
const valid =
given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!valid || !fresh) return res.sendStatus(401);
const event = JSON.parse(req.body.toString()); // { id, event, numberId, createdAt, data }
res.sendStatus(200); // Answer quickly, then do the work.
handleEvent(event).catch(console.error);
});
async function handleEvent(event) {
if (event.event === "message.received") {
const { message, contact } = event.data;
console.log(`${contact.name || contact.phone}: ${message.text}`);
}
}
app.listen(3000);Node.js without a framework
js
const http = require("http");
const crypto = require("crypto");
http
.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks).toString();
const timestamp = req.headers["x-kilichat-timestamp"] || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.KILICHAT_WEBHOOK_SECRET).update(`${timestamp}.${raw}`).digest("hex");
const given = req.headers["x-kilichat-signature"] || "";
const ok =
given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
res.writeHead(ok ? 200 : 401).end();
if (ok) console.log(JSON.parse(raw).event);
});
})
.listen(3000);Can I test the signature by hand?
Yes, with the command line. Save a body to body.json, then:
bash
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$(cat body.json)" | openssl dgst -sha256 -hmac "$KILICHAT_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -X POST https://example.com/kilichat-webhook \
-H "Content-Type: application/json" \
-H "X-KiliChat-Timestamp: $TS" \
-H "X-KiliChat-Signature: sha256=$SIG" \
--data-binary @body.jsonOr just click Send test on the webhook in KiliChat, which sends a real signed ping event.
Why is my signature check failing?
- Wrong secret. Each webhook has its own secret. If someone clicked New secret, the old one stopped working straight away.
- Body was changed. You're hashing parsed-and-reserialised JSON instead of the raw body.
- Missing timestamp. The string to sign is
<timestamp>.<body>, with the full stop. - Clock is off. Your server's clock is more than 5 minutes out, so fresh requests look old. Sync it with NTP.
- Prefix. The header value includes
sha256=; compare the whole string, or strip the prefix from both.
What if my secret leaks?
Click New secret on the webhook, confirm, and put the new secret on your server straight away. Requests signed with the old secret will fail your check from then on.