Skip to main content

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

ComponentWhere it comes from
secretThe endpoint's whsec_...
timestampThe X-Joinotify-Timestamp header, Unix seconds
raw bodyThe 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.