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.delivered

The 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 "", 200

PHP

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

  1. Use the raw body. Re-serializing changes the bytes and breaks the HMAC.
  2. Compare in constant time. hmac.compare_digest, hash_equals, crypto.timingSafeEqual — never ===.
  3. 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.