Verify signatures
Every delivery is signed. Verify before you trust a byte of it.
How the signature is built
Section titled “How the signature is built”X-MissLess-Timestamp: 1756112411X-MissLess-Signature: sha256=4f2c1e9a7b3d8e6f0a5c2b9d4e7f1a8c3b6d9e2f5a8c1b4d7e0f3a6c9b2d5e8f- Take the value of
X-MissLess-Timestamp(unix seconds, as a string). - Take the raw request body, exactly as received. Not re-serialised, not trimmed.
- Build the string
"<timestamp>.<raw body>". - Compute HMAC-SHA256 with your webhook secret as the key.
- Hex-encode it and prefix
sha256=. That must equalX-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==.
Replay window
Section titled “Replay window”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.
Node.js
Section titled “Node.js”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:
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.
Python
Section titled “Python”import hashlibimport hmacimport 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:
import jsonimport 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 "", 200Django: use request.body. FastAPI: await request.body(). Both give the raw bytes.
Checklist
Section titled “Checklist”- 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
401on 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.