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
- We concatenate the timestamp and raw request body:
timestamp + "." + raw_body - We compute HMAC-SHA256 of the concatenated string using your webhook secret
- We send the result as the
X-Webhook-Signatureheader:sha256=<hex_digest>
Verification Steps
- Extract
X-Webhook-SignatureandX-Webhook-Timestampheaders - Read the raw request body (do NOT parse JSON first)
- Compute:
HMAC-SHA256(secret, timestamp + "." + raw_body) - Prepend
sha256=to the hex digest - Constant-time compare your computed signature with the header value
- 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