--- title: "Verifying LightSpeed VT Webhooks" slug: "verifying-lightspeed-vt-webhooks" updated: 2026-08-27T18:04:17Z published: 2026-08-27T18:04:17Z canonical: "support.lightspeedvt.com/verifying-lightspeed-vt-webhooks" --- > ## Documentation Index > Fetch the complete documentation index at: https://support.lightspeedvt.com/llms.txt > Use this file to discover all available pages before exploring further. # Verifying LightSpeed VT Webhooks 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 ``` 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 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](https://gchq.github.io/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 `UTF8` — **not** `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