Skip to content

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:

  1. Hash the raw body bytes, not a re-serialised object.
  2. Reject a timestamp more than 300 seconds from now.
  3. Compare in constant time.
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();
},
);
<?php
const 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);
import hashlib
import hmac
import 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 "", 200

Sign a body yourself with the same secret and POST it to your handler:

Terminal window
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.