Verifying signatures

Every webhook request is signed with your endpoint's secret so you can prove it came from Vuon and was not tampered with. Verify the signature before acting on any payload.

The recipe

Each request carries three headers:

  • vuon-webhook-id — the event id (idempotency key, stable across retries).
  • vuon-webhook-timestamp — Unix seconds when this attempt was sent.
  • vuon-webhook-signature — one or more signatures of the form v1=<hex>, comma-separated.

The signature is computed as:

HMAC-SHA256(secret, "{timestamp}.{raw request body}")

hex-encoded and prefixed with v1=. To verify:

  1. Read the raw request body — before any JSON parsing or re-serialization.
  2. Concatenate the timestamp header, a literal ., and the raw body.
  3. Compute HMAC-SHA256 with your signing secret (the full whsec_... string) and hex-encode it.
  4. Compare against each comma-separated v1= value using a constant-time comparison. The request is authentic if any signature matches.
  5. Reject requests whose timestamp is more than 5 minutes from your current time to prevent replay.

TypeScript

verify.ts

import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

export function verifyWebhook(params: {
  secret: string;
  rawBody: string;
  timestampHeader: string;
  signatureHeader: string;
}): boolean {
  const timestamp = Number(params.timestampHeader);
  if (!Number.isFinite(timestamp)) {
    return false;
  }
  const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
  if (ageSeconds > TOLERANCE_SECONDS) {
    return false;
  }

  const expected = createHmac('sha256', params.secret)
    .update(`${timestamp}.${params.rawBody}`)
    .digest('hex');

  const expectedBuffer = Buffer.from(expected, 'hex');
  return params.signatureHeader.split(',').some((candidate) => {
    const value = candidate.trim().replace(/^v1=/, '');
    const candidateBuffer = Buffer.from(value, 'hex');
    if (candidateBuffer.length !== expectedBuffer.length) {
      return false;
    }
    return timingSafeEqual(candidateBuffer, expectedBuffer);
  });
}

With verifyWebhook from verify.ts above in scope:

Express usage

import express from 'express';

const app = express();

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook({
    secret: process.env.VUON_WEBHOOK_SECRET!,
    rawBody: req.body.toString('utf8'),
    timestampHeader: req.header('vuon-webhook-timestamp') ?? '',
    signatureHeader: req.header('vuon-webhook-signature') ?? '',
  });
  if (!ok) {
    return res.status(401).send('invalid signature');
  }
  const event = JSON.parse(req.body.toString('utf8'));
  // handle event.type / event.data here
  res.status(200).send('ok');
});

Python

verify.py

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_webhook(secret: str, raw_body: bytes, timestamp_header: str, signature_header: str) -> bool:
    try:
        timestamp = int(timestamp_header)
    except ValueError:
        return False
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False

    signed_payload = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()

    for candidate in signature_header.split(","):
        value = candidate.strip().removeprefix("v1=")
        if hmac.compare_digest(value, expected):
            return True
    return False

Flask usage

from flask import Flask, request
import json
import os

from verify import verify_webhook

app = Flask(__name__)


@app.post("/webhook")
def webhook():
    if not verify_webhook(
        os.environ["VUON_WEBHOOK_SECRET"],
        request.get_data(),
        request.headers.get("vuon-webhook-timestamp", ""),
        request.headers.get("vuon-webhook-signature", ""),
    ):
        return "invalid signature", 401
    event = json.loads(request.get_data())
    # handle event["type"] / event["data"] here
    return "ok", 200

Common mistakes

  • Parsing then re-serializing the body before hashing. JSON serializers reorder keys and change whitespace; always hash the raw bytes you received.
  • Comparing with ==. Use a constant-time comparison (timingSafeEqual, hmac.compare_digest) to avoid timing attacks.
  • Skipping the timestamp check. Without it, a captured request can be replayed indefinitely.

Was this page helpful?