Verificar firma HMAC
Tu URL de webhook es pública por necesidad — INGALCA Pay tiene que poder pegarle. Eso significa que cualquiera podría intentar mandarte requests falsos simulando pagos exitosos. La firma HMAC-SHA256 te permite verificar que cada webhook realmente viene de nosotros.
Cómo se calcula
firma = "sha256=" + hmac_sha256(secret, body_json_exacto)secret— tu webhook secret (whsec_...). Lo configurás en Configuración → Webhooks → Rotar secret. Es distinto por entorno.body_json_exacto— el cuerpo de la request byte por byte como llega. No lo parsees ni lo reformatees antes de firmar; usá el raw body.
Cómo verificar
import crypto from 'node:crypto';import express from 'express';
const app = express();const SECRET = process.env.INGALCA_WEBHOOK_SECRET;
// Importante: usar raw body, NO express.json() acáapp.post('/webhooks/ingalca', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-ingalca-signature']; const expected = 'sha256=' + crypto .createHmac('sha256', SECRET) .update(req.body) .digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { return res.status(401).send('invalid signature'); }
const event = JSON.parse(req.body.toString()); // ... procesar event ...
res.sendStatus(200);});Route::post('/webhooks/ingalca', function (Request $request) { $signature = $request->header('X-Ingalca-Signature'); $body = $request->getContent(); // raw body
$expected = 'sha256=' . hash_hmac('sha256', $body, env('INGALCA_WEBHOOK_SECRET'));
if (! hash_equals($expected, $signature)) { return response('invalid signature', 401); }
$event = json_decode($body, true); // ... procesar event ...
return response('', 200);});import hmacimport hashlibimport osfrom flask import Flask, request, abort
app = Flask(__name__)SECRET = os.environ['INGALCA_WEBHOOK_SECRET'].encode()
@app.post('/webhooks/ingalca')def webhook(): signature = request.headers.get('X-Ingalca-Signature', '') body = request.get_data() # raw bytes
expected = 'sha256=' + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected): abort(401)
event = request.get_json() # ... procesar event ...
return '', 200Qué NO hacer
- No uses
==para comparar firmas. Es vulnerable a timing attacks. Usácrypto.timingSafeEqual(Node),hash_equals(PHP),hmac.compare_digest(Python). - No re-serialices el body. Si convertís el JSON a objeto y lo volvés a stringify, vas a alterar espacios/orden y la firma no va a matchear.
- No expongas el secret en logs. Trátalo como una contraseña.
Rotar el secret
Si sospechás que tu secret se filtró, rotalo desde Configuración → Webhooks. El cambio es inmediato — los próximos webhooks van firmados con el secret nuevo. Asegurate de actualizar tu env var antes de rotar, sino vas a empezar a rechazar webhooks legítimos.
Replay attacks
El header X-Ingalca-Timestamp te permite rechazar webhooks demasiado viejos:
const timestamp = parseInt(req.headers['x-ingalca-timestamp'], 10);const ageSeconds = Math.floor(Date.now() / 1000) - timestamp;
if (ageSeconds > 300) { return res.status(401).send('webhook too old');}5 minutos de tolerancia suele ser razonable. Más tiempo te deja vulnerable a replays; menos tiempo puede causar falsos negativos por desfase de reloj.