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.
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:
- Validar a assinatura.
- Ler o pagamento com
GET /v1/payments/{id}. - 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 comots=1742505638683,v1=6a8f….tsé o timestamp ev1é 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.idda query string (?data.id=…) da URL da notificação. O Mercado Pago também o manda no corpo. - O SDK interpreta o
tsem 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. timingSafeEquale não===. Uma comparação comum para no primeiro byte diferente, e essa diferença de tempo vaza quanto da assinatura você acertou.timingSafeEqualleva 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 sejatype: "payment"com id. - Valida o
x-signaturecom o manifest exato etimingSafeEqual. - Rejeita timestamps velhos, aceitando segundos ou milissegundos.
- Lê o pagamento pela API antes de creditar.
- Escreve com um
upsertsobre 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.