LightSpeed VT signs every outbound webhook with an HMAC-SHA256 signature. This lets you confirm that a request actually came from LightSpeed VT and that it has not been altered or replayed in transit.
This article shows you how to verify those signatures in your webhook receiver.
Already receiving webhooks? No change is required. The request body is identical to what you receive today, and the two new headers can be safely ignored. You can adopt signature verification on your own timeline.
Before you begin
You'll need your shared secret — a long random string issued to you when your webhook integration is set up.
Store it securely in a secrets manager, environment variable, or vault. Never commit it to source control, and never write it to a log.
If you don't have a secret yet, contact your LightSpeed VT account manager to have one issued.
What we send
Every webhook request includes two additional headers:
| Header | Description | Example |
|---|---|---|
LSVT-Webhook-Time |
Unix timestamp in seconds (UTC) at the moment we signed the request | 1747700000 |
LSVT-Webhook-Signature |
HMAC-SHA256 of {timestamp}.{raw_body}, lowercase hex |
962c39a827506fc2cff5a09112f9012eae5e546efcaa833ad1029b0e1004e6c4 |
The request body itself is unchanged — it's the same JSON event payload you'd receive without signing.
The signature is computed over the raw bytes of that body. Any reserialization — different key order, added or removed whitespace, changed escaping — produces different bytes and will break verification.
How to verify a request
For every incoming request:
- Read the raw body bytes before any JSON parsing or middleware processing. Most frameworks require this to be configured explicitly.
- Read both headers:
LSVT-Webhook-TimeandLSVT-Webhook-Signature. - Reject the request if either header is missing.
- Reject the request if the timestamp is more than 5 minutes old. This is your replay protection. Allow for reasonable clock skew between our servers and yours.
- Recompute the signature:
HMAC_SHA256(your_secret, "{timestamp}.{raw_body}"), output as lowercase hex. - Compare your value to the header value using a constant-time comparison. Reject if they don't match.
- Only then parse the body as JSON and process the event.
Return 401 for any rejected request. Return a 2xx once you've accepted the event.
Code examples
Each example below reads the raw body, checks the timestamp window, recomputes the HMAC, and compares in constant time.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
// Capture the raw body before any JSON parsing.
// Scope this to the webhook route so JSON parsing still works elsewhere.
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const timestamp = req.header('LSVT-Webhook-Time');
const signature = req.header('LSVT-Webhook-Signature');
const rawBody = req.body.toString('utf8');
if (!timestamp || !signature) {
return res.status(401).send('missing signature headers');
}
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) {
return res.status(401).send('timestamp missing or outside 5-minute window');
}
const expected = crypto
.createHmac('sha256', process.env.LSVT_WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// timingSafeEqual throws if the buffers differ in length, so check that first.
// Comparing the hex strings as UTF-8 also avoids silent truncation of
// malformed hex, which Buffer.from(value, 'hex') would do without erroring.
const expectedBuf = Buffer.from(expected, 'utf8');
const signatureBuf = Buffer.from(signature, 'utf8');
if (
expectedBuf.length !== signatureBuf.length ||
!crypto.timingSafeEqual(expectedBuf, signatureBuf)
) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(rawBody);
// ... process event
res.status(200).send('ok');
});
Python (Flask)
import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['LSVT_WEBHOOK_SECRET'].encode('utf-8')
@app.post('/webhook')
def webhook():
timestamp = request.headers.get('LSVT-Webhook-Time')
signature = request.headers.get('LSVT-Webhook-Signature')
raw_body = request.get_data() # bytes, before any parsing
if not timestamp or not signature:
abort(401)
try:
sent_at = int(timestamp)
except ValueError:
abort(401) # malformed header, not a server error
if abs(time.time() - sent_at) > 300:
abort(401)
# Concatenate bytes directly so the body never round-trips through str.
expected = hmac.new(
SECRET,
timestamp.encode('utf-8') + b'.' + raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.get_json()
# ... process event
return 'ok', 200
PHP
<?php
$secret = getenv('LSVT_WEBHOOK_SECRET');
$timestamp = $_SERVER['HTTP_LSVT_WEBHOOK_TIME'] ?? '';
$signature = $_SERVER['HTTP_LSVT_WEBHOOK_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input');
if (!$timestamp || !$signature || !ctype_digit($timestamp)) {
http_response_code(401); exit;
}
if (abs(time() - intval($timestamp)) > 300) {
http_response_code(401); exit;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401); exit;
}
$event = json_decode($rawBody, true);
// ... process event
http_response_code(200);
C# (ASP.NET Core)
[HttpPost("webhook")]
public async Task<IActionResult> Handle()
{
// Note: this action takes no [FromBody] parameter, so model binding
// has not already consumed the request body.
var timestamp = Request.Headers["LSVT-Webhook-Time"].ToString();
var signature = Request.Headers["LSVT-Webhook-Signature"].ToString();
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync();
if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature))
return Unauthorized();
if (!long.TryParse(timestamp, out var sentAt))
return Unauthorized();
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - sentAt) > 300)
return Unauthorized();
var secret = Environment.GetEnvironmentVariable("LSVT_WEBHOOK_SECRET");
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
var expected = Convert.ToHexString(hash).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature)))
return Unauthorized();
// ... process rawBody
return Ok();
}
Checking a signature by hand
While you're wiring up your receiver — or when you're debugging a verification failure — it can help to recompute the HMAC manually and compare. CyberChef runs entirely in your browser, so nothing is transmitted anywhere.
Use a test secret if you can. Even though CyberChef is client-side, pasting a production secret into a browser exposes it to browser history, extensions, and anything else with access to that tab. Ask your account manager for a non-production secret for debugging.
- Open CyberChef.
- In the Operations panel on the left, search for
HMACand drag it into the Recipe panel. - Configure the operation:
- Key: paste your shared secret
- Key format:
Latin1orUTF8— notHexorBase64, which reinterpret your secret as encoded bytes and produce the wrong hash - Hash function:
SHA256
- In the Input box, paste exactly
{timestamp}.{raw_body}— the value fromLSVT-Webhook-Time, a literal period, then the raw request body. No surrounding quotes, no extra whitespace, no trailing newline. - The Output box shows the computed HMAC. It should match
LSVT-Webhook-Signatureexactly.
This is a debugging aid only. In production, always verify in code on every request.
Common pitfalls
Parsing the body before reading it raw. Express, Spring, Rails, and similar frameworks consume the body to populate a parsed object as soon as middleware runs. After that, the original bytes are gone. Capture the raw body first, and configure your framework to delay parsing on the webhook route specifically.
Using == or .equals() instead of a constant-time comparison. Standard equality is vulnerable to timing attacks. Use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hash_equals (PHP), CryptographicOperations.FixedTimeEquals (.NET), or the equivalent in your language.
Returning 500 instead of 401 on malformed input. A non-numeric timestamp or a signature that isn't valid hex should be rejected, not raised as a server error. Otherwise our retry logic treats a bad request as a transient failure on your end.
Treating the secret like a password. It's a high-entropy random value. Don't trim whitespace, don't lowercase it, don't normalize it. Use it byte-for-byte as issued.
Logging the secret. Keep it out of error messages, stack traces, debug logs, and analytics events. If you log incoming webhook headers, redact LSVT-Webhook-Signature as well — it isn't secret, but logging it makes mismatches harder to investigate later.
Clock skew. If your server's clock drifts more than about 5 minutes from real time, valid requests will look stale. Confirm NTP is running and that you're comparing against UTC rather than local time.
Wrong CyberChef key format. The Key format dropdown must be Latin1 or UTF8. Some saved recipes default to Hex, which silently produces the wrong hash without erroring.
Retries
If we don't receive an HTTP 2xx response within our timeout, we retry the delivery up to 4 times.
Each retry carries a fresh timestamp and a fresh signature — it is not a byte-identical replay of the original request. Your replay-protection window applies independently to each delivery, measured against the timestamp in that request.
Troubleshooting
If your verification is rejecting requests you believe are valid, the most common cause is body reserialization between receipt and hashing. Confirm you're hashing the raw bytes exactly as received, not a parsed-and-re-stringified version of the JSON.
Still stuck? Contact your LightSpeed VT account manager and include:
- A sample request — headers plus the raw body
- The signature value your code is computing
- The signature value in the
LSVT-Webhook-Signatureheader