How to verify webhook signatures to ensure authenticity.

Signature Verification

Every webhook request is signed with HMAC-SHA256. Always verify the signature to confirm the request came from VTU API and hasn't been tampered with.

How Signing Works

  1. We concatenate the timestamp and raw request body: timestamp + "." + raw_body
  2. We compute HMAC-SHA256 of the concatenated string using your webhook secret
  3. We send the result as the X-Webhook-Signature header: sha256=<hex_digest>

Verification Steps

  1. Extract X-Webhook-Signature and X-Webhook-Timestamp headers
  2. Read the raw request body (do NOT parse JSON first)
  3. Compute: HMAC-SHA256(secret, timestamp + "." + raw_body)
  4. Prepend sha256= to the hex digest
  5. Constant-time compare your computed signature with the header value
  6. Optionally, reject requests where the timestamp is older than 5 minutes (replay protection)

Generating Your Webhook Secret

Generate a webhook secret from your VTU API dashboard under Developer Settings → Webhooks. Store this secret securely — it's used to verify all incoming webhooks.


Code Examples

Node.js (Express)

const crypto = require('crypto');

function constTimeCompare(a, b) {
  return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const secret = process.env.WEBHOOK_SECRET;
  const sigHeader = req.get('X-Webhook-Signature') || '';
  const timestamp = req.get('X-Webhook-Timestamp') || '';

  if (!sigHeader || !timestamp) return res.status(400).send('missing headers');

  const raw = req.body; // Buffer
  const signed = timestamp + '.' + raw.toString('utf8');
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', secret).update(signed).digest('hex');

  try {
    if (!constTimeCompare(expected, sigHeader))
      return res.status(401).send('invalid signature');

    const payload = JSON.parse(raw.toString('utf8'));
    // process payload.event / payload.data
    res.status(200).send('ok');
  } catch (err) {
    res.status(400).send('bad payload');
  }
});

PHP (Laravel)

use Illuminate\Http\Request;

public function handle(Request $request)
{
    $secret    = env('WEBHOOK_SECRET');
    $sig       = $request->header('X-Webhook-Signature');
    $timestamp = $request->header('X-Webhook-Timestamp');

    if (!$sig || !$timestamp) {
        return response('missing headers', 400);
    }

    $raw      = $request->getContent();
    $signed   = $timestamp . '.' . $raw;
    $expected = 'sha256=' . hash_hmac('sha256', $signed, $secret);

    if (!hash_equals($expected, $sig)) {
        return response('invalid signature', 401);
    }

    $payload = json_decode($raw, true);
    // process $payload['event'], $payload['data']

    return response('ok', 200);
}

Python (Flask)

import hmac
import hashlib
import time
from flask import request, abort

SECRET = b'your-webhook-secret'

@app.route('/webhooks', methods=['POST'])
def webhooks():
    sig = request.headers.get('X-Webhook-Signature')
    ts  = request.headers.get('X-Webhook-Timestamp')

    if not sig or not ts:
        abort(400)

    raw    = request.get_data()  # bytes
    signed = ts.encode() + b'.' + raw
    expected = 'sha256=' + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, sig):
        abort(401)

    # Replay protection: reject timestamps older than 5 minutes
    if abs(time.time() - float(ts)) > 300:
        abort(400)

    payload = request.get_json()
    # process payload
    return '', 200

Java (Spring Boot)

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;

@RestController
public class WebhookController {

    private static final String SECRET = System.getenv("WEBHOOK_SECRET");

    @PostMapping("/webhooks")
    public ResponseEntity<String> handle(
        @RequestHeader("X-Webhook-Signature") String signature,
        @RequestHeader("X-Webhook-Timestamp") String timestamp,
        HttpServletRequest request
    ) throws Exception {
        byte[] raw = request.getInputStream().readAllBytes();
        String signed = timestamp + "." + new String(raw, StandardCharsets.UTF_8);

        Mac mac = Mac.getInstance("HmacSHA256");
        SecretKeySpec keySpec = new SecretKeySpec(
            SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"
        );
        mac.init(keySpec);
        byte[] computed = mac.doFinal(signed.getBytes(StandardCharsets.UTF_8));
        String expected = "sha256=" + bytesToHex(computed);

        // constant-time compare
        if (!MessageDigest.isEqual(
            expected.getBytes(StandardCharsets.UTF_8),
            signature.getBytes(StandardCharsets.UTF_8)
        )) {
            return ResponseEntity.status(401).body("invalid signature");
        }

        // Parse JSON from raw and process
        return ResponseEntity.ok("ok");
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            sb.append(String.format("%02x", b & 0xff));
        }
        return sb.toString();
    }
}

Security Checklist

  • Always use constant-time comparison (never ==)
  • Read the raw body before parsing JSON
  • Validate the timestamp to prevent replay attacks (±5 minutes)
  • Store your webhook secret in an environment variable, not in code
  • Use HTTPS for your webhook endpoint