Contents

Verify webhook signatures

Verify webhook signatures

Short answer: recompute HMAC-SHA256(secret, "<t>.<raw body>") and compare it with v1 from the Hivi-Signature header. Reject mismatches and timestamps older than five minutes.

The header

Hivi-Signature: t=1761400800,v1=5f1c0e...
  • t: when the request was signed, in Unix seconds.
  • v1: the lowercase hex HMAC-SHA256 of t, a dot, and the raw body, keyed with the endpoint's signing secret (whsec_..., the whole string).

Steps

  1. Read the raw body. Don't parse and re-stringify it first.
  2. Split the header into t and v1.
  3. Compute HMAC-SHA256(secret, t + "." + body) as hex.
  4. Compare with v1 using a constant-time comparison.
  5. Refuse the request if t is more than 300 seconds from now.
  6. Answer with a 2xx status quickly, then do the slow work.

Node.js (Express)

const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.HIVI_WEBHOOK_SECRET;

app.post('/hivi-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Hivi-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const body = req.body.toString('utf8');
  const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.${body}`).digest('hex');
  const valid =
    parts.v1 && parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  if (!valid || !fresh) return res.status(400).send('bad signature');

  const event = JSON.parse(body);
  res.sendStatus(204);
  // handle event.type / event.data here, once per event.id
});

Python (Flask)

import hmac, hashlib, time, os, json
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["HIVI_WEBHOOK_SECRET"].encode()

@app.post("/hivi-webhook")
def hivi_webhook():
    parts = dict(p.split("=", 1) for p in request.headers.get("Hivi-Signature", "").split(",") if "=" in p)
    body = request.get_data()  # raw bytes
    expected = hmac.new(SECRET, f"{parts.get('t')}.".encode() + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(400)
    if abs(time.time() - int(parts.get("t", 0))) > 300:
        abort(400)
    event = json.loads(body)
    return "", 204

Other headers

HeaderUse
Hivi-Event-IdThe event id (evt_...). The same event sent again keeps it: use it to skip repeats.
Hivi-Event-TypeThe event type, also in the body as type.
Hivi-Delivery-IdThis delivery attempt's record in your delivery history.

Related

Answers are general operational information only — not legal, tax, regulatory, clinical, privacy, immigration, or professional advice. Requirements vary by matter, client, and regulator.

Frequently asked questions

What does hivi-x sign?
The timestamp, a dot, and the raw request body: "<t>.<body>". The signature is HMAC-SHA256 with the endpoint's signing secret, written in lowercase hex.
Why does my signature never match?
Usually because the body was parsed and re-serialized before hashing. Hash the raw bytes exactly as received. Also check that you use the secret of this endpoint, including the whsec_ prefix.
What if I lose the signing secret?
Choose Replace secret on the endpoint in Account → Webhooks. The new secret is used for the next request, so update your receiver at the same time.

Organize client document intake with checklists

hivi-x helps Ontario professionals collect client documents with reusable checklists, secure Canadian hosting, and exportable PDF packages.

Verify hivi-x Webhook Signatures (HMAC-SHA256) | hivi-x Docs