HMAC verification
MANDATORY. Without signature verification, any attacker who manages to guess the URL could send fake webhooks and trigger the receiver’s business logic with fabricated data.
Every webhook POST includes the X-Lxs-Signature header with the HMAC-SHA256 signature of the raw body, computed with the corresponding API key’s webhookSecret. The receiver must recompute the HMAC and compare it, aborting with 401 if it does not match.
Algorithm
expected = HEX(HMAC-SHA256(webhookSecret, raw_body))
header = "sha256=" + expectedThe resulting value must be compared against the X-Lxs-Signature header (without the sha256= prefix) using a timing-safe comparison.
Critical rules
- The raw body must be captured before parsing it. The already-parsed JSON must not be re-serialized: key order and spacing can vary and break the HMAC.
- The comparison must be performed with a timing-safe function (never with
===or==). - If it does not match, respond with
401and discard the event. - If it matches, process the event (preferably asynchronously, to respond quickly).
Node.js example (Express)
It is important to register the express.raw({ type: 'application/json' }) middleware before any global JSON parser; otherwise req.body arrives already re-serialized and the signature check fails.
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const app = express();
// IMPORTANT: raw body, not automatic JSON
app.post('/webhooks/lxs',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = (req.headers['x-lxs-signature'] || '').replace(/^sha256=/, '');
const expected = createHmac('sha256', process.env.LXS_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (
signature.length !== expected.length ||
!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
procesarEvento(event);
res.status(200).end();
}
);Python example (Flask)
In Flask, use request.get_data() to get the body as bytes; never reuse the value returned by get_json() to compute the HMAC.
import os, hmac, hashlib
from flask import request, abort, Flask
app = Flask(__name__)
@app.post('/webhooks/lxs')
def webhook():
signature = (request.headers.get('X-Lxs-Signature') or '').replace('sha256=', '')
expected = hmac.new(
os.environ['LXS_WEBHOOK_SECRET'].encode(),
request.get_data(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
procesar_evento(event)
return '', 200C# example (.NET)
In ASP.NET, it is best to read the body directly from Request.Body with a StreamReader and use CryptographicOperations.FixedTimeEquals for the timing-safe comparison.
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("webhooks/lxs")]
public class LxsWebhookController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Handle()
{
// Capture raw body
using StreamReader sr = new(Request.Body);
string body = await sr.ReadToEndAsync();
string presented = Request.Headers["X-Lxs-Signature"]
.ToString()
.Replace("sha256=", "", StringComparison.OrdinalIgnoreCase);
byte[] secret = Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("LXS_WEBHOOK_SECRET")!);
byte[] expected = HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(body));
string expectedHex = Convert.ToHexString(expected).ToLowerInvariant();
if (
presented.Length != expectedHex.Length ||
!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(presented),
Encoding.UTF8.GetBytes(expectedHex)))
{
return Unauthorized();
}
var evento = JsonSerializer.Deserialize<HaciendaEvent>(body)!;
await ProcesarEventoAsync(evento);
return Ok();
}
}PHP example
In PHP, file_get_contents('php://input') returns the raw body, and hash_equals performs the timing-safe comparison.
<?php
$rawBody = file_get_contents('php://input');
$signature = str_replace('sha256=', '', $_SERVER['HTTP_X_LXS_SIGNATURE'] ?? '');
$expected = hash_hmac('sha256', $rawBody, getenv('LXS_WEBHOOK_SECRET'));
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$evento = json_decode($rawBody, true);
procesar_evento($evento);
http_response_code(200);