Skip to Content
v1.0Documentación oficial de la API LX CloudPos · estable en producción
V1WebhooksVerificación HMAC

Verificación HMAC

🔒 OBLIGATORIO. Sin verificar la firma, cualquier atacante que adivine tu URL podría enviarte webhooks falsos y disparar tu lógica de negocio con datos inventados.

Cada POST de webhook trae el header X-Lxs-Signature con la firma HMAC-SHA256 del body crudo, calculada con el webhookSecret de tu API key. Tu trabajo es recomputar el HMAC y compararlo, abortando con 401 si no coincide.

Algoritmo

expected = HEX(HMAC-SHA256(webhookSecret, raw_body)) header = "sha256=" + expected

Comparalo con el header X-Lxs-Signature (sin el prefijo sha256=) usando comparación timing-safe.

Reglas críticas

  1. Capturá el body crudo antes de parsearlo. No re-serialices el JSON ya parseado: el orden de claves y los espacios pueden cambiar y romper el HMAC.
  2. Compará con una función timing-safe (nunca con === ni ==).
  3. Si no coincide, respondé 401 y descartá el evento.
  4. Si coincide, procesá el evento (idealmente de forma asíncrona para responder rápido).

Ejemplo Node.js (Express)

Es importante registrar el middleware express.raw({ type: 'application/json' }) antes de cualquier parser JSON global; de lo contrario req.body ya viene serializado de vuelta y la firma falla.

import express from 'express'; import { createHmac, timingSafeEqual } from 'crypto'; const app = express(); // IMPORTANTE: raw body, no JSON automático 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(); } );

Ejemplo Python (Flask)

En Flask usá request.get_data() para conseguir el body en bytes; jamás reuses lo que devuelve get_json() para calcular el 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 '', 200

Ejemplo C# (.NET)

En ASP.NET conviene leer el body directamente de Request.Body con un StreamReader y usar CryptographicOperations.FixedTimeEquals para la comparación timing-safe.

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() { // Captura 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(); } }

Ejemplo PHP

En PHP, file_get_contents('php://input') te da el body crudo y hash_equals hace la comparación timing-safe.

<?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);