Webhook Verification
Every webhook HeyVisa sends includes a HeyVisa-Signature header. Verifying it confirms the request originated from us and was not tampered with in transit. Always verify before acting on a payload.
The signature header
http
HeyVisa-Signature: t=1718099274,v1=4f3a1c8b9e2d6f0a7c5b1d3e2a8f9c0dThe header contains a timestamp t (Unix seconds) and one or more signatures (v1). Each signature is an HMAC SHA-256 of {t}.{rawBody}using your workspace's webhook signing secret (visible once at creation from Settings → Webhooks).
Verification steps
- Capture the raw request body. Do not re-serialise parsed JSON — whitespace changes will break the signature.
- Parse
tandv1from the header. - Reject the request if
|now - t| > 5 minto defend against replay attacks. - Compute
HMAC_SHA256(secret, "{t}.{body}")and compare withv1in constant time. - If they match, process the event. Otherwise return 400.
Node.js (Express)
typescript
import crypto from "node:crypto";
import express from "express";
const app = express();
// Important: use the raw body, not the JSON-parsed object.
app.post(
"/heyvisa/hooks",
express.raw({ type: "application/json" }),
(req, res) => {
const signatureHeader = req.header("HeyVisa-Signature") ?? "";
const secret = process.env.HEYVISA_WEBHOOK_SECRET!;
if (!verify(req.body, signatureHeader, secret, 5 * 60)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// handle event ...
res.sendStatus(200);
}
);
function verify(
rawBody: Buffer,
header: string,
secret: string,
toleranceSeconds: number
) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string])
);
const t = Number(parts.t);
const v1 = parts.v1;
// Reject replays older than tolerance.
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const signed = `${t}.${rawBody.toString("utf8")}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signed)
.digest("hex");
// Constant-time compare.
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(v1, "hex")
);
}Python (Flask)
python
import hmac, hashlib, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["HEYVISA_WEBHOOK_SECRET"].encode()
@app.post("/heyvisa/hooks")
def hooks():
raw = request.get_data()
header = request.headers.get("HeyVisa-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
abort(400)
# Reject replays older than 5 minutes.
if abs(time.time() - t) > 300:
abort(400)
signed_payload = f"{t}.{raw.decode('utf-8')}".encode()
expected = hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(400)
# handle event ...
return "", 200Rotate compromised secrets immediately
If your secret is leaked, rotate it from Settings → Webhooks. The dashboard supports dual-secret rotation: deploy the new secret first, switch over, then revoke the old one.