Verify the signature
Every delivery is signed. Validating the signature is what stops someone from forging messages to your endpoint — the URL is public, so without validation anyone can post to it.
The algorithm
signature = HMAC-SHA256(secret, "{timestamp}.{raw body}")
In hexadecimal, prefixed with sha256=.
| Component | Where it comes from |
|---|---|
secret | The endpoint's whsec_... |
timestamp | The X-Joinotify-Timestamp header, Unix seconds |
raw body | The body bytes, before any parsing |
The timestamp is inside the HMAC on purpose: without it, anyone who intercepted a valid delivery could replay it forever. Compare it against the current clock and reject anything too old — 300 seconds is the recommended tolerance.
Node.js + Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const TOLERANCE_SECONDS = 300;
// The webhook route needs the RAW body. Register it BEFORE any JSON
// middleware — express.json() reserializes the payload and the signature
// stops matching.
app.post('/webhooks/joinotify', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Joinotify-Signature-256') ?? '';
const timestamp = req.get('X-Joinotify-Timestamp') ?? '';
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return res.sendStatus(401);
}
const expected =
'sha256=' +
crypto.createHmac('sha256', process.env.JOINOTIFY_WEBHOOK_SECRET).update(`${timestamp}.${req.body}`).digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
res.sendStatus(200);
processInBackground(JSON.parse(req.body.toString()));
});
app.use(express.json()); // other routes
Python + Flask
import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
TOLERANCE_SECONDS = 300
SECRET = os.environ["JOINOTIFY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/joinotify")
def webhook():
signature = request.headers.get("X-Joinotify-Signature-256", "")
timestamp = request.headers.get("X-Joinotify-Timestamp", "0")
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return "", 401
# request.get_data() returns the raw bytes; request.json would parse them.
body = request.get_data()
expected = "sha256=" + hmac.new(SECRET, f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
return "", 401
enqueue(request.get_json())
return "", 200
PHP
<?php
$tolerance = 300;
$secret = getenv('JOINOTIFY_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_JOINOTIFY_SIGNATURE_256'] ?? '';
$timestamp = $_SERVER['HTTP_X_JOINOTIFY_TIMESTAMP'] ?? '0';
$body = file_get_contents('php://input');
if (abs(time() - (int) $timestamp) > $tolerance) {
http_response_code(401);
exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
http_response_code(200);
fastcgi_finish_request(); // respond before processing
enqueue(json_decode($body, true));
Why the signature doesn't match
It's almost always one of these four.
The body was parsed first. express.json(), bodyParser, request.json — they all
reserialize and change whitespace and key order. Use the raw bytes.
The timestamp was left out. The HMAC covers "{timestamp}.{body}", not the body
alone. This is the most common mistake for people coming from Meta, where the signature
covers only the body.
The sha256= prefix was dropped. It's part of the compared value.
A proxy altered the body. Some reverse proxies rewrite JSON. If you're behind one, confirm it passes the body through untouched.
Always use constant-time comparison
crypto.timingSafeEqual, hmac.compare_digest, hash_equals. Never ==.
An ordinary comparison bails at the first differing byte, and that timing difference is measurable — enough to recover the correct signature byte by byte. The functions above take the same time regardless of where the difference is.
Testing
curl -X POST https://api.joinotify.com/webhook-endpoints/whe_01J8ZK.../test \
-H 'Authorization: Bearer sk_live_xxx'
responseStatus: 200 in the response means your validation passed. 401 means it is
rejecting legitimate deliveries — go back through the list above.