Ohio Sales Tax Calculator

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
  1. t: The integer Unix timestamp when the request was dispatched.
  2. v1: The hexadecimal HMAC-SHA256 signature calculated over ${timestamp}.${raw_body}.

Verification Implementations

Node.js (Express)
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");
  }
});
Python (FastAPI / Flask)
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)
PHP
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']);
}