heyvisa

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=4f3a1c8b9e2d6f0a7c5b1d3e2a8f9c0d

The 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

  1. Capture the raw request body. Do not re-serialise parsed JSON — whitespace changes will break the signature.
  2. Parse t and v1 from the header.
  3. Reject the request if |now - t| > 5 min to defend against replay attacks.
  4. Compute HMAC_SHA256(secret, "{t}.{body}") and compare with v1 in constant time.
  5. 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 "", 200
Rotate 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.