Verifying signatures
Every webhook we send is signed. Verify it before trusting the payload — your notification URL is public, so anyone who discovers it can POST to it.
Headers
HTTP
X-JKASMS-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-JKASMS-Timestamp: 1789946450
X-JKASMS-Key-Id: pk_live_xxxxxxxxxxxx
X-JKASMS-Event: message.deliveredThe signature is an HMAC-SHA256 over `${timestamp}.${rawBody}`, keyed with the whsec_… secret you were shown when you created the API key.
Verify against the raw request body, exactly as received. Parsing the JSON and re-serializing it changes key order and whitespace, and the signature will never match.
Node.js
Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
// express.raw, not express.json — we need the exact bytes.
app.post("/webhooks/jkasms", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("X-JKASMS-Signature") ?? "";
const timestamp = req.header("X-JKASMS-Timestamp") ?? "";
const rawBody = req.body.toString("utf8");
// Reject anything older than five minutes. Without this, a captured
// payload can be replayed indefinitely.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > 300) {
return res.status(401).send("stale");
}
const expected = crypto
.createHmac("sha256", process.env.JKASMS_WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const provided = signature.startsWith("v1=") ? signature.slice(3) : signature;
// timingSafeEqual throws on length mismatch, so check first.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(provided, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(rawBody);
// Acknowledge fast, then do the work. We retry on any non-2xx.
res.sendStatus(200);
handleDeliveryUpdate(event);
});Python
Python
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["JKASMS_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/jkasms")
def jkasms_webhook():
signature = request.headers.get("X-JKASMS-Signature", "")
timestamp = request.headers.get("X-JKASMS-Timestamp", "")
raw_body = request.get_data(as_text=True) # exact bytes, not request.json
try:
if abs(int(time.time()) - int(timestamp)) > 300:
abort(401)
except ValueError:
abort(401)
expected = hmac.new(
SECRET, f"{timestamp}.{raw_body}".encode(), hashlib.sha256
).hexdigest()
provided = signature[3:] if signature.startswith("v1=") else signature
if not hmac.compare_digest(expected, provided):
abort(401)
event = request.get_json()
handle_delivery_update(event)
return "", 200PHP
PHP
<?php
$signature = $_SERVER["HTTP_X_JKASMS_SIGNATURE"] ?? "";
$timestamp = $_SERVER["HTTP_X_JKASMS_TIMESTAMP"] ?? "";
$rawBody = file_get_contents("php://input");
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit("stale");
}
$expected = hash_hmac("sha256", "{$timestamp}.{$rawBody}", getenv("JKASMS_WEBHOOK_SECRET"));
$provided = str_starts_with($signature, "v1=") ? substr($signature, 3) : $signature;
if (!hash_equals($expected, $provided)) {
http_response_code(401);
exit("bad signature");
}
$event = json_decode($rawBody, true);
http_response_code(200);
handle_delivery_update($event);Three rules
- Use the raw body. Re-serializing changes the bytes and breaks the HMAC.
- Compare in constant time.
hmac.compare_digest,hash_equals,crypto.timingSafeEqual— never===. - Check the timestamp. A valid signature stays valid forever; the timestamp window is what stops a captured payload being replayed next week.
Multiple keys
X-JKASMS-Key-Id tells you which key signed the request, so you can hold several whsec_ secrets during a rotation and pick the right one. Rotation is revoke-and-recreate: there is no way to change a key's secret in place.