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.
O que chega
Seção intitulada “O que chega”Toda entrega é um POST com três headers:
POST /webhooks HTTP/1.1Content-Type: application/jsonWebhook-Id: evt_01JB8XZQ4T9M2NKPWV3RYCF7HDWebhook-Timestamp: 1757337600Webhook-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>. |
Como a assinatura é feita
Seção intitulada “Como a assinatura é feita”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.
Três coisas que quebram a verificação
Seção intitulada “Três coisas que quebram a verificação”Na ordem em que costumam acontecer.
- 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.
- 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. - 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.
O código
Seção intitulada “O código”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) })}<?php
// $corpoCru = file_get_contents('php://input'); no Laravel, $request->getContent().function notyfacil_verificar(string $corpoCru, array $headers, string $secret): bool{ $headers = array_change_key_case($headers, CASE_LOWER); $id = $headers['webhook-id'] ?? null; $timestamp = $headers['webhook-timestamp'] ?? null;
if ($id === null || $timestamp === null) return false; if (abs(time() - (int) $timestamp) > 300) return false;
$esperada = 'v1,' . base64_encode( hash_hmac('sha256', "{$id}.{$timestamp}.{$corpoCru}", $secret, true) );
foreach (explode(' ', $headers['webhook-signature'] ?? '') as $recebida) { if (hash_equals($esperada, $recebida)) return true; }
return false;}using System.Security.Cryptography;using System.Text;
// No ASP.NET Core, leia o corpo cru antes de qualquer binding:// using var leitor = new StreamReader(Request.Body); var corpoCru = await leitor.ReadToEndAsync();static bool Verificar(string corpoCru, string? id, string? timestamp, string? assinaturas, string secret){ if (id is null || !long.TryParse(timestamp, out var segundos)) { return false; }
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - segundos) > 300) { return false; }
var mac = HMACSHA256.HashData( Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{id}.{timestamp}.{corpoCru}")); var esperada = Encoding.UTF8.GetBytes("v1," + Convert.ToBase64String(mac));
return (assinaturas ?? "") .Split(' ', StringSplitOptions.RemoveEmptyEntries) .Any(recebida => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(recebida), esperada));}import base64import hashlibimport hmacimport time
# No Flask, request.get_data(); no Django, request.body. Sempre os bytes crus.def verificar(corpo_cru: bytes, headers, secret: str) -> bool: webhook_id = headers.get("Webhook-Id") timestamp = headers.get("Webhook-Timestamp") if not webhook_id or not timestamp: return False
if abs(time.time() - int(timestamp)) > 300: return False
conteudo = f"{webhook_id}.{timestamp}.".encode() + corpo_cru digest = hmac.new(secret.encode(), conteudo, hashlib.sha256).digest() esperada = "v1," + base64.b64encode(digest).decode()
return any( hmac.compare_digest(esperada, recebida) for recebida in headers.get("Webhook-Signature", "").split(" ") )Assinatura que não confere: responda 401 e não processe nada.
Mais de uma assinatura no header
Seção intitulada “Mais de uma assinatura no header”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.
Testando
Seção intitulada “Testando”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.
Webhooks de recebimento
Seção intitulada “Webhooks de recebimento”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.