Webhooks do Mercado Pago: como validar o x-signature

Valide webhooks do Mercado Pago em Node.js: manifest do x-signature, HMAC-SHA256, comparação em tempo constante, anti-replay e idempotência.

MC
Martin Coll

Um webhook do Mercado Pago é um POST que avisa que algo mudou, por exemplo "o pagamento 1234 mudou de status". Se você o processa sem validar, qualquer pessoa que conheça o seu endpoint pode marcar um pagamento como aprovado. Este guia mostra como validá-lo direito em Node.js com o código do adapter do useDeploy, e o que fazer depois para não creditar o mesmo pagamento duas vezes.

O webhook não traz o pagamento

O corpo de uma notificação de pagamento é basicamente um tipo e um id:

{ "type": "payment", "action": "payment.updated", "data": { "id": "1234567890" } }

Não traz status, nem valor, nem external_reference. É um aviso: aconteceu algo com o pagamento 1234567890, vá conferir. Por isso a ordem é sempre:

  1. Validar a assinatura.
  2. Ler o pagamento com GET /v1/payments/{id}.
  3. Agir conforme o status, de forma idempotente.

Qualquer notificação que não seja de pagamento, ou que não tenha id, responda com 200 e ignore. Não é um erro, e se você responder erro o Mercado Pago vai tentar de novo.

O que o Mercado Pago assina

Cada notificação traz dois headers:

  • x-signature: algo como ts=1742505638683,v1=6a8f…. ts é o timestamp e v1 é o HMAC.
  • x-request-id: o id da requisição.

A assinatura é um HMAC-SHA256 em hexadecimal, calculado com o secret de webhooks da sua aplicação sobre este manifest:

id:{data.id};request-id:{x-request-id};ts:{ts};

É assim que o validador do SDK oficial de Node (mercadopago/sdk-nodejs) o monta, e ele ainda omite os pares cujo valor está ausente. Dois detalhes para levar em conta:

  • O SDK pega o data.id da query string (?data.id=…) da URL da notificação. O Mercado Pago também o manda no corpo.
  • O SDK interpreta o ts em segundos, mas a documentação mostra exemplos de 10 e de 13 dígitos. Se você checa a idade do timestamp, aceite os dois formatos.

As notificações de QR Code não vêm assinadas, então não aplique este validador a elas.

O validador

Este é o que usamos, sem dependências:

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: o timestamp assinado precisa ser recente.
  // Aceita segundos ou milissegundos.
  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');

  // Comparação em tempo constante; timingSafeEqual exige o mesmo tamanho.
  const a = Buffer.from(hash, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Três coisas que este código faz de propósito:

  • O manifest é exato, com os ; do final incluídos. Um separador errado e todas as assinaturas falham: é a causa mais comum de "meu secret está errado" quando o secret está certo.
  • timingSafeEqual e não ===. Uma comparação comum para no primeiro byte diferente, e essa diferença de tempo vaza quanto da assinatura você acertou. timingSafeEqual leva sempre o mesmo tempo, mas lança erro se os tamanhos não coincidem: por isso o tamanho é comparado antes.
  • Janela de tempo. Sem ela, alguém que capture uma notificação válida poderia reenviá-la para sempre. Com 5 minutos de tolerância, uma assinatura velha deixa de servir.

Você precisa do corpo cru

Se o seu framework faz o parse do JSON antes de chegar ao seu handler, você ainda consegue ler o data.id; o que não pode é perder a query string. No useDeploy o endpoint de webhooks recebe o corpo sem parse (POST /api/v1/billing/webhook) e o adapter faz todo o trabalho. Num Route Handler do Next.js você tem request.url e await request.text().

Depois de validar: ler o pagamento

const payment = await getPayment(dataId); // GET /v1/payments/{id}

if (payment.status === 'approved') {
  // creditar a payment.external_reference
} else if (payment.status === 'rejected' || payment.status === 'cancelled') {
  // marcar como falho
}

O getPayment está em API do Mercado Pago com Node.js e TypeScript (em espanhol). O status real sempre sai da API, nunca do corpo do webhook.

Idempotência: o mesmo webhook vai chegar mais de uma vez

O Mercado Pago tenta de novo se você não responder a tempo: espera 22 segundos por uma resposta 200 ou 201, depois repete a cada 15 minutos, e a partir da terceira tentativa espaça as repetições, que seguem por dias. Some a isso os seus próprios testes reenviando notificações. Se o seu handler faz INSERT ou saldo += valor, cada repetição é um bug.

A solução é fazer a escrita ser segura de repetir, usando o id do pagamento como chave única:

await prisma.purchase.upsert({
  where: { externalOrderId: String(payment.id) }, // único no schema
  create: { /* o comprovante */ },
  update: {}, // vazio de propósito: um duplicado não sobrescreve nada
});

A segunda, terceira ou quinta entrega do mesmo pagamento terminam na mesma linha. No useDeploy, além disso, o evento normalizado passa por um outbox transacional: se o listener falha, ele é reexecutado sozinho, e a resposta 200 ao Mercado Pago não depende disso.

Checklist

  • Você ignora (com 200) o que não seja type: "payment" com id.
  • Valida o x-signature com o manifest exato e timingSafeEqual.
  • Rejeita timestamps velhos, aceitando segundos ou milissegundos.
  • Lê o pagamento pela API antes de creditar.
  • Escreve com um upsert sobre o id do pagamento.
  • Responde rápido; o trabalho pesado vai para uma fila ou um outbox.

Perguntas frequentes

Onde está o secret? Na sua aplicação, na configuração de Webhooks do Mercado Pago. Não é o access token.

Posso usar o SDK oficial em vez deste código? Pode. O SDK de Node traz um validador de assinaturas. Passe a ele os valores crus de x-signature, x-request-id e data.id, sem normalizá-los.

O que respondo se a assinatura não valida? Um erro (por exemplo 401) e não processe nada. Uma notificação legítima que for reenviada vai chegar de novo.


Este post é a versão em português de MercadoPago webhooks done right, atualizada com a janela anti-replay que o adapter tem hoje. No useDeploy tudo isso já vem integrado: assinatura, leitura do pagamento, outbox e idempotência. Veja a integração com o Mercado Pago.