Verifying LightSpeed VT Webhooks

Prev Next

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:

  1. Read the raw body bytes before any JSON parsing or middleware processing. Most frameworks require this to be configured explicitly.
  2. Read both headers: LSVT-Webhook-Time and LSVT-Webhook-Signature.
  3. Reject the request if either header is missing.
  4. 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.
  5. Recompute the signature: HMAC_SHA256(your_secret, "{timestamp}.{raw_body}"), output as lowercase hex.
  6. Compare your value to the header value using a constant-time comparison. Reject if they don't match.
  7. 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.

  1. Open CyberChef.
  2. In the Operations panel on the left, search for HMAC and drag it into the Recipe panel.
  3. Configure the operation:
    • Key: paste your shared secret
    • Key format: Latin1 or UTF8not Hex or Base64, which reinterpret your secret as encoded bytes and produce the wrong hash
    • Hash function: SHA256
  4. In the Input box, paste exactly {timestamp}.{raw_body} — the value from LSVT-Webhook-Time, a literal period, then the raw request body. No surrounding quotes, no extra whitespace, no trailing newline.
  5. The Output box shows the computed HMAC. It should match LSVT-Webhook-Signature exactly.

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-Signature header