Skip to content

Verify signatures

Every delivery is signed. Verify before you trust a byte of it.

X-MissLess-Timestamp: 1756112411
X-MissLess-Signature: sha256=4f2c1e9a7b3d8e6f0a5c2b9d4e7f1a8c3b6d9e2f5a8c1b4d7e0f3a6c9b2d5e8f
  1. Take the value of X-MissLess-Timestamp (unix seconds, as a string).
  2. Take the raw request body, exactly as received. Not re-serialised, not trimmed.
  3. Build the string "<timestamp>.<raw body>".
  4. Compute HMAC-SHA256 with your webhook secret as the key.
  5. Hex-encode it and prefix sha256=. That must equal X-MissLess-Signature.

Two rules that account for almost every “signature mismatch”:

  • Use the raw body. A JSON middleware that parses and re-serialises the body changes whitespace and key order. Read the bytes first, parse after verifying.
  • Compare in constant time. Use your platform’s timing-safe comparison, not === or ==.

The timestamp is in the signed string so an attacker cannot lift a valid signature and resend it later. Reject deliveries whose timestamp is more than 5 minutes from your clock. Retries are re-signed with a fresh timestamp, so a tight window does not break them.

verify.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* @param {Buffer|string} rawBody the request body, unparsed
* @param {Record<string, string|string[]|undefined>} headers lower-cased header names
* @param {string} secret MISSLESS_WEBHOOK_SECRET
* @param {number} tolerance max age in seconds, default 300
*/
export function verifyMissLess(rawBody, headers, secret, tolerance = 300) {
const timestamp = String(headers['x-missless-timestamp'] ?? '');
const signature = String(headers['x-missless-signature'] ?? '');
if (!timestamp || !signature || !secret) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > tolerance) return false;
const expected =
'sha256=' +
createHmac('sha256', secret).update(timestamp + '.').update(rawBody).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}

Express, reading the body raw:

server.mjs
import express from 'express';
import { verifyMissLess } from './verify.mjs';
const app = express();
app.post('/webhooks/missless', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyMissLess(req.body, req.headers, process.env.MISSLESS_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
res.status(200).end();
handle(event);
});

Next.js route handlers give you the raw text with await req.text(); there is a complete example in the Next.js guide.

verify.py
import hashlib
import hmac
import time
def verify_missless(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
"""headers: any mapping with case-insensitive .get(), e.g. Flask's request.headers"""
timestamp = headers.get("X-MissLess-Timestamp", "")
signature = headers.get("X-MissLess-Signature", "")
if not timestamp or not signature or not secret:
return False
try:
if abs(int(time.time()) - int(timestamp)) > tolerance:
return False
except ValueError:
return False
mac = hmac.new(secret.encode("utf-8"), timestamp.encode("utf-8") + b"." + raw_body, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
return hmac.compare_digest(expected, signature)

Flask, reading the body raw:

app.py
import json
import os
from flask import Flask, request
from verify import verify_missless
app = Flask(__name__)
@app.post("/webhooks/missless")
def missless_webhook():
raw = request.get_data() # bytes, before any JSON parsing
if not verify_missless(raw, request.headers, os.environ["MISSLESS_WEBHOOK_SECRET"]):
return "", 401
event = json.loads(raw)
enqueue(event) # do the work off the request thread
return "", 200

Django: use request.body. FastAPI: await request.body(). Both give the raw bytes.

  • Raw bytes in, not a re-serialised object.
  • timestamp + "." + body, in that order, with the literal dot.
  • Hex digest, lower case, prefixed sha256=.
  • Timing-safe comparison.
  • Reject if the timestamp is more than 300 seconds off.
  • Return 401 on failure and log it. Do not return the reason to the caller.
  • When rotating the secret, accept either the old or the new one for a few minutes.

To test without waiting for a real event, call POST /v1/webhook/test and check that the ping verifies.