Verify a webhook signature
Each delivery carries X-SimplifyLocal-Timestamp (Unix seconds) and X-SimplifyLocal-Signature (sha256= + hex). The signature is HMAC-SHA256, keyed with your endpoint secret, over the timestamp, a period, and the raw request body. Full rules are on Webhooks.
Three things every implementation must get right:
- Hash the raw body bytes, not a re-serialised object.
- Reject a timestamp more than 300 seconds from now.
- Compare in constant time.
Node.js
Section titled “Node.js”import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifySimplifyLocal({ secret, rawBody, headers }) { const timestamp = headers["x-simplifylocal-timestamp"]; const signature = headers["x-simplifylocal-signature"] ?? "";
const ts = Number(timestamp); if (!Number.isFinite(ts)) return false; if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) return false;
if (!signature.startsWith("sha256=")) return false; const received = Buffer.from(signature.slice("sha256=".length), "hex"); const expected = Buffer.from( createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"), "hex", ); if (received.length === 0 || received.length !== expected.length) return false; return timingSafeEqual(received, expected);}With Express, read the raw body before any JSON parser touches it:
import express from "express";
const app = express();app.post( "/simplifylocal/webhook", express.raw({ type: "application/json" }), (req, res) => { const ok = verifySimplifyLocal({ secret: process.env.SL_WEBHOOK_SECRET, rawBody: req.body.toString("utf8"), headers: req.headers, }); if (!ok) return res.status(401).end(); const event = JSON.parse(req.body.toString("utf8")); // queue work keyed on event.delivery.id, then: res.status(200).end(); },);<?phpconst TOLERANCE_SECONDS = 300;
function verify_simplifylocal(string $secret, string $rawBody, array $headers): bool{ $timestamp = $headers['X-SimplifyLocal-Timestamp'] ?? ''; $signature = $headers['X-SimplifyLocal-Signature'] ?? '';
if (!is_numeric($timestamp)) { return false; } if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) { return false; } if (strncmp($signature, 'sha256=', 7) !== 0) { return false; }
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, substr($signature, 7));}
$rawBody = file_get_contents('php://input');$headers = [ 'X-SimplifyLocal-Timestamp' => $_SERVER['HTTP_X_SIMPLIFYLOCAL_TIMESTAMP'] ?? '', 'X-SimplifyLocal-Signature' => $_SERVER['HTTP_X_SIMPLIFYLOCAL_SIGNATURE'] ?? '',];
if (!verify_simplifylocal(getenv('SL_WEBHOOK_SECRET'), $rawBody, $headers)) { http_response_code(401); exit;}
$event = json_decode($rawBody, true);// queue work keyed on $event['delivery']['id'], then:http_response_code(200);Python
Section titled “Python”import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify_simplifylocal(secret: str, raw_body: bytes, headers: dict) -> bool: timestamp = headers.get("X-SimplifyLocal-Timestamp", "") signature = headers.get("X-SimplifyLocal-Signature", "")
try: ts = int(timestamp) except ValueError: return False if abs(int(time.time()) - ts) > TOLERANCE_SECONDS: return False if not signature.startswith("sha256="): return False
signed = timestamp.encode("utf-8") + b"." + raw_body expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature[len("sha256="):])With Flask:
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/simplifylocal/webhook")def webhook(): if not verify_simplifylocal(SL_WEBHOOK_SECRET, request.get_data(), request.headers): abort(401) event = request.get_json(force=True) # queue work keyed on event["delivery"]["id"], then: return "", 200Testing locally
Section titled “Testing locally”Sign a body yourself with the same secret and POST it to your handler:
SECRET='your-secret'BODY='{"event":"job.published","occurred_at":"2026-04-13T09:02:11.000Z","business":{"slug":"{slug}"},"job":{"slug":"house-washing-marietta"},"delivery":{"id":"00000000-0000-4000-8000-000000000001","attempt":1}}'TS=$(date +%s)SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:3000/simplifylocal/webhook \ -H "Content-Type: application/json" \ -H "X-SimplifyLocal-Event: job.published" \ -H "X-SimplifyLocal-Delivery: 00000000-0000-4000-8000-000000000001" \ -H "X-SimplifyLocal-Timestamp: $TS" \ -H "X-SimplifyLocal-Signature: sha256=$SIG" \ --data-binary "$BODY"Change one byte of BODY after signing and confirm your handler answers 401.