Skip to main content

Documentation Index

Fetch the complete documentation index at: https://vantagesolutions.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

Every webhook delivery is signed with HMAC-SHA256 using the per-subscription secret returned at create time. Always verify the signature before trusting the body. An unverified webhook handler is a public unauthenticated endpoint that an attacker can spoof at will.

Signature headers

Two headers accompany every delivery:
HeaderValue
X-VC-Signaturesha256=<hex> — HMAC-SHA256 digest.
X-VC-TimestampUnix timestamp in milliseconds at which the event was signed.
The signature is computed over the byte string:
<timestamp>.<body>
Where <timestamp> is the literal value of the X-VC-Timestamp header (digits only), the separator is a single literal dot character, and <body> is the exact bytes of the request body as received over the wire — no re-serialization, no whitespace adjustment. The HMAC key is the subscription secret returned by POST /api/v1/partner/webhooks (the value starting with whsec_).

Verification algorithm

  1. Read the raw body bytes before any JSON parsing or middleware that might reformat them.
  2. Read the X-VC-Signature and X-VC-Timestamp headers. Reject if either is missing.
  3. Strip the sha256= prefix from the signature header; reject if not present.
  4. Parse the timestamp as an integer (milliseconds since epoch). Reject if non-numeric.
  5. Reject if abs(now_ms - timestamp_ms) > 300_000 (5 minute replay window).
  6. Compute HMAC-SHA256(secret, f"{timestamp}." + body_bytes).hexdigest().
  7. Constant-time compare the computed digest against the received digest. Reject on mismatch.
Only if all seven checks pass should you trust the body and process the event.

Reference implementations

Python

import hashlib
import hmac
import time

REPLAY_WINDOW_MS = 5 * 60 * 1000  # 5 minutes


def verify_webhook(
    *,
    secret: str,
    body: bytes,
    signature_header: str,
    timestamp_header: str,
    now_ms: int | None = None,
) -> bool:
    if not signature_header.startswith("sha256="):
        return False
    received = signature_header[len("sha256="):]

    try:
        ts_ms = int(timestamp_header)
    except (TypeError, ValueError):
        return False

    current_ms = now_ms if now_ms is not None else int(time.time() * 1000)
    if abs(current_ms - ts_ms) > REPLAY_WINDOW_MS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        msg=f"{ts_ms}.".encode("utf-8") + body,
        digestmod=hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(received, expected)

Node.js

const crypto = require("crypto");

const REPLAY_WINDOW_MS = 5 * 60 * 1000;

function verifyWebhook({ secret, body, signatureHeader, timestampHeader, nowMs }) {
  if (!signatureHeader || !signatureHeader.startsWith("sha256=")) {
    return false;
  }
  const received = signatureHeader.slice("sha256=".length);

  const tsMs = parseInt(timestampHeader, 10);
  if (Number.isNaN(tsMs)) {
    return false;
  }

  const currentMs = nowMs ?? Date.now();
  if (Math.abs(currentMs - tsMs) > REPLAY_WINDOW_MS) {
    return false;
  }

  const signed = Buffer.concat([Buffer.from(`${tsMs}.`, "utf8"), body]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signed)
    .digest("hex");

  const receivedBuf = Buffer.from(received, "hex");
  const expectedBuf = Buffer.from(expected, "hex");
  if (receivedBuf.length !== expectedBuf.length) {
    return false;
  }
  return crypto.timingSafeEqual(receivedBuf, expectedBuf);
}
In Node.js, your web framework must give you the raw body bytes, not a parsed JSON object. With Express, use express.raw({ type: 'application/json' }) for the webhook route specifically. With Next.js API routes, read req as a stream and reconstruct the buffer. JSON middleware that has already parsed and re-serialized the body will produce mismatched signatures.

What the checks defend against

CheckDefends against
sha256= prefixAlgorithm-confusion attacks (forcing HMAC-MD5 or no algorithm).
Timestamp parseableMalformed-header crash / type confusion.
Timestamp within 5 minReplay attacks — an attacker who captures a valid delivery cannot replay it later.
HMAC compareForgery — without the secret, an attacker cannot produce a valid signature.
Constant-time compareTiming oracle — an attacker probing one byte at a time learns nothing about the expected digest.

Secret storage

Store the subscription secret as you would any high-value credential:
  • Environment variable or secret manager. Never commit it to a code repository.
  • One per subscription. If you have multiple subscription URLs (e.g., staging + production), each has its own independent secret.
  • Rotate by deletion + recreation. v1 has no in-place secret rotation. To rotate: create a second subscription with the new URL/secret, verify traffic flows, delete the old one.

What to do if verification fails

Failed verification is always a hard reject — return a non-2xx status from your handler (recommended: 401 Unauthorized so failures are obvious in monitoring). Do not log the body, do not enqueue for retry on your side, do not “process the event but flag it.” A forged or replayed webhook should be discarded with the same finality as an unauthenticated API call. Persistent verification failures on legitimate traffic almost always trace to one of three causes:
  1. Wrong secret. The handler is configured with a different secret than the subscription it’s receiving. Re-check against GET /api/v1/partner/webhooks to confirm the subscription ID matches.
  2. Body mutation. A middleware layer parsed and re-serialized the JSON before your handler saw it. Reroute the raw bytes.
  3. Clock skew. Your server’s clock drifted more than 5 minutes from UTC. Run ntpd or chronyd.