Webhooks de Mercado Pago: validar x-signature
Validá webhooks de Mercado Pago en Node.js: manifest de x-signature, HMAC-SHA256, comparación en tiempo constante, anti-replay e idempotencia.
Un webhook de Mercado Pago es un POST que te avisa que algo cambió, por ejemplo
"el pago 1234 cambió de estado". Si lo procesás sin validarlo, cualquiera que
conozca tu endpoint puede marcarse un pago como aprobado. Esta guía muestra cómo
validarlo bien en Node.js con el código del adapter de useDeploy, y qué
hacer después para no acreditar dos veces el mismo pago.
El webhook no trae el pago
El cuerpo de una notificación de pago es básicamente un tipo y un id:
{ "type": "payment", "action": "payment.updated", "data": { "id": "1234567890" } }No trae estado, ni monto, ni external_reference. Es un aviso: pasó algo con
el pago 1234567890, andá a fijarte. Por eso el orden siempre es:
- Validar la firma.
- Leer el pago con
GET /v1/payments/{id}. - Actuar según el estado, de forma idempotente.
Cualquier notificación que no sea de pago, o que no tenga id, se responde con
200 y se ignora. No es un error, y si respondés error Mercado Pago la va a
reintentar.
Qué firma Mercado Pago
Cada notificación trae dos headers:
x-signature: algo comots=1742505638683,v1=6a8f….tses el timestamp yv1es el HMAC.x-request-id: el id del request.
La firma es un HMAC-SHA256 en hexadecimal, calculado con el secret de webhooks de tu aplicación sobre este manifest:
id:{data.id};request-id:{x-request-id};ts:{ts};Así lo arma el validador del SDK oficial de Node (mercadopago/sdk-nodejs),
que además omite los pares cuyo valor falta. Dos detalles para tener en
cuenta:
- El SDK toma
data.idde la query string (?data.id=…) de la URL de la notificación. Mercado Pago también lo manda en el cuerpo. - El SDK interpreta
tsen segundos, pero la documentación muestra ejemplos de 10 y de 13 dígitos. Si chequeás la antigüedad del timestamp, aceptá los dos formatos.
Las notificaciones de Código QR no vienen firmadas, así que no les apliques este validador.
El validador
Este es el que usamos, sin dependencias:
import crypto from 'node:crypto';
const MAX_SKEW_MS = 5 * 60 * 1000; // 5 minutos
export function isValidMercadoPagoSignature(opts: {
xSignature: string;
xRequestId: string;
dataId: string;
secret: string;
now?: number;
}): boolean {
// "ts=…,v1=…" → { ts, v1 }
const parts = Object.fromEntries(
opts.xSignature.split(',').map((part) => {
const [key, ...val] = part.trim().split('=');
return [key, val.join('=')];
}),
);
const ts = parts['ts'];
const hash = parts['v1'];
if (!ts || !hash) return false;
// Anti-replay: el timestamp firmado tiene que ser reciente.
// Acepta segundos o milisegundos.
const tsNum = Number(ts);
if (!Number.isFinite(tsNum)) return false;
const tsMs = tsNum < 1e12 ? tsNum * 1000 : tsNum;
if (Math.abs((opts.now ?? Date.now()) - tsMs) > MAX_SKEW_MS) return false;
const manifest = `id:${opts.dataId};request-id:${opts.xRequestId};ts:${ts};`;
const expected = crypto.createHmac('sha256', opts.secret).update(manifest).digest('hex');
// Comparación en tiempo constante; timingSafeEqual exige mismo largo.
const a = Buffer.from(hash, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Tres cosas que este código hace a propósito:
- El manifest es exacto, con los
;del final incluidos. Un separador mal puesto y todas las firmas fallan: es la causa más común de "mi secret está mal" cuando el secret está bien. timingSafeEqualy no===. Una comparación común corta en el primer byte distinto, y esa diferencia de tiempo filtra cuánto de la firma acertaste.timingSafeEqualtarda siempre lo mismo, pero tira error si los largos no coinciden: por eso se compara el largo antes.- Ventana de tiempo. Sin ella, alguien que capture una notificación válida podría reenviarla para siempre. Con 5 minutos de tolerancia, una firma vieja deja de servir.
Necesitás el cuerpo crudo
Si tu framework parsea el JSON antes de que llegue a tu handler, igual podés
leer data.id; lo que no podés es perder la query string. En useDeploy el
endpoint de webhooks recibe el cuerpo sin parsear (POST /api/v1/billing/webhook) y el adapter hace todo el trabajo. En un Route Handler
de Next.js tenés request.url y await request.text().
Después de validar: leer el pago
const payment = await getPayment(dataId); // GET /v1/payments/{id}
if (payment.status === 'approved') {
// acreditar a payment.external_reference
} else if (payment.status === 'rejected' || payment.status === 'cancelled') {
// marcar como fallido
}getPayment está en API de Mercado Pago con Node.js y
TypeScript. El estado real siempre sale
de la API, nunca del cuerpo del webhook.
Idempotencia: el mismo webhook va a llegar más de una vez
Mercado Pago reintenta si no le respondés a tiempo: espera 22 segundos una
respuesta 200 o 201, después reintenta cada 15 minutos, y a partir del
tercer intento espacia los reintentos, que siguen durante días. Sumale tus
propias pruebas reenviando notificaciones. Si tu handler hace INSERT o
saldo += monto, cada repetición es un bug.
La solución es que la escritura sea segura de repetir, usando el id del pago como clave única:
await prisma.purchase.upsert({
where: { externalOrderId: String(payment.id) }, // único en el schema
create: { /* el comprobante */ },
update: {}, // vacío a propósito: un duplicado no pisa nada
});La segunda, tercera o quinta entrega del mismo pago terminan en la misma fila.
En useDeploy, además, el evento normalizado pasa por un outbox transaccional:
si el listener falla, se reintenta solo, y la respuesta 200 a Mercado Pago no
depende de eso.
Checklist
- Ignorás (con
200) lo que no seatype: "payment"con id. - Validás
x-signaturecon el manifest exacto ytimingSafeEqual. - Rechazás timestamps viejos, aceptando segundos o milisegundos.
- Leés el pago con la API antes de acreditar.
- Escribís con un
upsertsobre el id del pago. - Respondés rápido; el trabajo pesado va a una cola o un outbox.
Preguntas frecuentes
¿Dónde está el secret? En tu aplicación, en la configuración de Webhooks de Mercado Pago. No es el access token.
¿Puedo usar el SDK oficial en vez de este código?
Sí. El SDK de Node trae un validador de firmas. Pasale los valores crudos de
x-signature, x-request-id y data.id, sin normalizarlos.
¿Qué respondo si la firma no valida?
Un error (por ejemplo 401) y no proceses nada. Una notificación legítima
reintentada va a volver a llegar.
Este post es la versión en español de MercadoPago webhooks done right, actualizada con la ventana anti-replay que hoy tiene el adapter. En useDeploy todo esto ya viene integrado: firma, lectura del pago, outbox e idempotencia. Mirá la integración con Mercado Pago.