Verifying Webhook Signatures
To ensure webhook deliveries originate exclusively from the Ohio Sales Tax platform and have not been altered in transit, all outgoing requests are signed using an HMAC-SHA256 hash.
Signature Structure
The X-Webhook-Signature header contains a comma-separated list of key-value pairs:
X-Webhook-Signature: t=1791036000,v1=9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a
t: The integer Unix timestamp when the request was dispatched.v1: The hexadecimal HMAC-SHA256 signature calculated over${timestamp}.${raw_body}.
Verification Implementations
import crypto from "crypto";
app.post("/webhooks/ohio-tax", express.raw({ type: "application/json" }), (req, res) => {
const signatureHeader = req.headers["x-webhook-signature"];
const secret = process.env.OHIO_TAX_WEBHOOK_SECRET;
if (!signatureHeader || !secret) {
return res.status(401).send("Missing signature or secret");
}
const parts = signatureHeader.split(",");
const t = parts.find((p) => p.startsWith("t="))?.slice(2);
const v1 = parts.find((p) => p.startsWith("v1="))?.slice(3);
if (!t || !v1) {
return res.status(401).send("Invalid signature header format");
}
// Prevent replay attacks (reject if older than 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(t, 10)) > 300) {
return res.status(400).send("Timestamp outside tolerance window");
}
// Compute expected HMAC
const cleanSecret = secret.replace(/^whsec_/, "");
const payload = `${t}.${req.body.toString("utf8")}`;
const expectedSignature = crypto
.createHmac("sha256", cleanSecret)
.update(payload)
.digest("hex");
if (crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expectedSignature))) {
const event = JSON.parse(req.body.toString("utf8"));
console.log("Verified Ohio Tax event received:", event.type);
res.status(200).json({ received: true });
} else {
res.status(401).send("Invalid webhook signature");
}
});
import hmac
import hashlib
import time
def verify_ohio_webhook(raw_body_bytes: bytes, sig_header: str, secret: str) -> bool:
if not sig_header or not secret:
return False
clean_secret = secret.removeprefix("whsec_").encode("utf-8")
parts = dict(p.split("=", 1) for p in sig_header.split(",") if "=" in p)
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return False
# Prevent replay attacks within 5 minutes
if abs(time.time() - int(t)) > 300:
return False
signed_payload = f"{t}.".encode("utf-8") + raw_body_bytes
expected_sig = hmac.new(clean_secret, signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected_sig)
function verifyOhioWebhook($rawBody, $signatureHeader, $secret) {
$cleanSecret = preg_replace('/^whsec_/', '', $secret);
$parts = [];
foreach (explode(',', $signatureHeader) as $pair) {
$kv = explode('=', $pair, 2);
if (count($kv) === 2) $parts[$kv[0]] = $kv[1];
}
if (!isset($parts['t']) || !isset($parts['v1'])) return false;
// 5-minute replay tolerance
if (abs(time() - intval($parts['t'])) > 300) return false;
$payload = $parts['t'] . '.' . $rawBody;
$expected = hash_hmac('sha256', $payload, $cleanSecret);
return hash_equals($expected, $parts['v1']);
}