Pular para o conteúdo

Verificando a assinatura

Esta página é para quem implementa o lado que recebe os webhooks. Se alguém te mandou este link, é porque o sistema dele usa o Notyfacil para te avisar de eventos. Você não precisa de conta nem de chave: precisa do secret do seu endpoint, que ele te passa, e deste código.

Verificar a assinatura é o que garante que a requisição veio de quem diz e que o corpo não foi alterado no caminho. Sem isso, qualquer um que descubra a sua URL consegue fabricar um pedido.pago.

Toda entrega é um POST com três headers:

POST /webhooks HTTP/1.1
Content-Type: application/json
Webhook-Id: evt_01JB8XZQ4T9M2NKPWV3RYCF7HD
Webhook-Timestamp: 1757337600
Webhook-Signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"id":"evt_01JB8XZQ4T9M2NKPWV3RYCF7HD","type":"pedido.pago","createdAt":"2026-09-08T13:20:00.000Z","data":{"pedidoId":1234}}
Header O que é
Webhook-Id O id do evento. É o mesmo em todas as tentativas e no replay, e é por ele que se deduplica.
Webhook-Timestamp O instante do envio, em segundos Unix.
Webhook-Signature Uma ou mais assinaturas, separadas por espaço, cada uma no formato v1,<base64>.

A assinatura é um HMAC-SHA256, codificado em base64 e prefixado com v1,, calculado sobre esta string exata:

{Webhook-Id}.{Webhook-Timestamp}.{corpo cru}

A chave do HMAC é o secret inteiro, com o prefixo whsec_, em UTF-8.

Na ordem em que costumam acontecer.

  1. Reserializar o JSON. Assine o corpo cru, exatamente como chegou. Um framework que desserializa e serializa de volta muda espaços ou a ordem das chaves, e o HMAC deixa de bater. Leia os bytes antes de qualquer parsing.
  2. Comparar com ==. Use comparação em tempo constante. A comparação comum para no primeiro caractere diferente e vaza, pelo tempo de resposta, o quanto do valor o atacante acertou.
  3. Ignorar o timestamp. Rejeite o que estiver a mais de 5 minutos do seu relógio. Sem isso, uma requisição capturada hoje continua válida para sempre.
import crypto from 'node:crypto'
// Com Express, a rota precisa de express.raw({ type: 'application/json' }):
// o corpo tem que chegar como Buffer, antes de qualquer JSON.parse.
export function verificar(corpoCru, headers, secret) {
const id = headers['webhook-id']
const timestamp = headers['webhook-timestamp']
const recebidas = (headers['webhook-signature'] ?? '').split(' ')
if (!id || !timestamp) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const esperada = Buffer.from(
'v1,' +
crypto
.createHmac('sha256', secret)
.update(`${id}.${timestamp}.`)
.update(corpoCru)
.digest('base64'),
)
return recebidas.some((recebida) => {
const candidata = Buffer.from(recebida)
return candidata.length === esperada.length && crypto.timingSafeEqual(candidata, esperada)
})
}

Assinatura que não confere: responda 401 e não processe nada.

Quando o secret do seu endpoint é rotacionado, por 24 horas as entregas vão assinadas com o secret antigo e o novo, as duas no mesmo header:

Webhook-Signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= v1,Q1gXh7T0mP2lV8cN4rWjE6yKzA9bU3dF5sH0oLqRtCw=

Aceite se qualquer uma conferir. É isso que deixa trocar o secret do seu lado com calma, dentro da janela, sem perder nenhuma entrega. O código acima já faz isso.

Quem cadastrou o seu endpoint pode disparar uma entrega de teste a qualquer momento. Ela chega assinada como uma entrega real, com o tipo endpoint.test e um Webhook-Id que começa com evt_test_. É a forma mais rápida de confirmar que o seu código aceita, e de descobrir que ele rejeita, antes do primeiro evento de verdade.

Se o que você recebe é um webhook repassado de um provedor externo (o Notyfacil recebeu do Mercado Pago e mandou para você), a verificação é idêntica, com duas diferenças no que chega: o Webhook-Id começa com in_, e o corpo é o do provedor, sem alteração, com o Content-Type original. A assinatura continua sendo sobre o corpo cru.