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.

MC
Martin Coll

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:

  1. Validar la firma.
  2. Leer el pago con GET /v1/payments/{id}.
  3. 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 como ts=1742505638683,v1=6a8f…. ts es el timestamp y v1 es 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.id de la query string (?data.id=…) de la URL de la notificación. Mercado Pago también lo manda en el cuerpo.
  • El SDK interpreta ts en 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.
  • timingSafeEqual y no ===. Una comparación común corta en el primer byte distinto, y esa diferencia de tiempo filtra cuánto de la firma acertaste. timingSafeEqual tarda 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 sea type: "payment" con id.
  • Validás x-signature con el manifest exacto y timingSafeEqual.
  • Rechazás timestamps viejos, aceptando segundos o milisegundos.
  • Leés el pago con la API antes de acreditar.
  • Escribís con un upsert sobre 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.