POST para ela. Por isso toda entrega da Zapfy vem assinada com um
segredo que só você e a Zapfy conhecem. Verificar a assinatura garante três coisas:
- Origem — a requisição foi enviada pela Zapfy.
- Integridade — o corpo chegou exatamente como saiu, sem nenhum byte alterado.
- Frescor — não é uma requisição antiga, capturada e reenviada (replay).
As entregas seguem a especificação aberta Standard Webhooks,
a mesma usada por vários provedores de webhook. Na prática, você valida com a
biblioteca oficial do padrão, sem escrever código de criptografia.
O secret
Cada webhook tem o seu própriosecret, gerado pela Zapfy na criação. Ele volta
uma única vez, na resposta do POST que cria o webhook:
secret como uma senha: variável de ambiente ou cofre de segredos, nunca
no código-fonte nem em log. Com ele, qualquer um consegue forjar uma entrega válida.
Headers da entrega
O tipo do evento não tem header próprio: ele está no campo
type do corpo, que é
assinado.
Verificando com a biblioteca oficial
Instale a biblioteca do Standard Webhooks e passe a ela o corpo cru da requisição, os headers e o seusecret. Ela confere a assinatura, rejeita
requisições com mais de 5 minutos e devolve o evento já parseado.
Verificando sem biblioteca
Se preferir não adicionar dependência, a verificação cabe em poucas linhas. A assinatura é um HMAC-SHA256 do ID, do timestamp e do corpo cru, separados por ponto. A chave é osecret sem o prefixo whsec_, decodificado de base64 — não
a string em si:
- Leia o corpo cru da requisição, antes de qualquer parse de JSON.
- Leia
webhook-id,webhook-timestampewebhook-signature. Se faltar algum, rejeite. - Rejeite se o timestamp estiver a mais de 5 minutos do seu relógio.
- Calcule a assinatura esperada como acima.
- O header pode trazer mais de uma assinatura, separadas por espaço. Aceite se qualquer uma delas for igual à esperada, usando comparação de tempo constante.
Node.js
webhook-timestamp para
“rejuvenescer” uma requisição capturada, nem mudar o webhook-id para escapar da sua
deduplicação.
Proteção contra replay
A assinatura sozinha prova que a requisição saiu da Zapfy, mas não quando. Sem a checagem do timestamp, alguém que capturasse uma entrega válida poderia reenviá-la dias depois e ela passaria. A tolerância de 5 minutos fecha essa janela — a biblioteca oficial já faz essa checagem.- O
webhook-timestampé a hora do envio, carimbada a cada tentativa. Uma reentrega feita depois de uma falha chega com timestamp novo e passa na tolerância normalmente. - Ele não é o
timestampdo corpo, que é a hora do evento e se mantém igual em todas as tentativas. - Mantenha o relógio do servidor sincronizado (NTP). Um relógio desviado alguns minutos passa a rejeitar entregas legítimas.
webhook-id, ou o id do corpo — são o mesmo valor), que você já precisa
ter porque a entrega é at-least-once. Um replay dentro da janela carrega o ID de um
evento já processado e é descartado; e como o ID é assinado, trocá-lo invalida a
requisição.
Erros comuns
A assinatura nunca bate
A assinatura nunca bate
Quase sempre é o corpo. Se o framework fizer o parse do JSON e você verificar o
resultado de
JSON.stringify(req.body), os bytes mudam (espaços, ordem de chaves,
escapes) e o HMAC é outro. Use o corpo cru, exatamente como chegou — no
Express, express.raw; no Flask, request.get_data(); no PHP, php://input.Implementei na mão e não bate, mas a biblioteca funciona
Implementei na mão e não bate, mas a biblioteca funciona
A chave do HMAC é o
secret decodificado de base64, depois de remover o
prefixo whsec_. Usar a string whsec_... direto como chave gera outra
assinatura. A assinatura também é base64, não hex.Timestamp sempre fora da tolerância
Timestamp sempre fora da tolerância
O
webhook-timestamp está em segundos. Comparar com Date.now() (milissegundos)
sem dividir por 1000 rejeita tudo. Confira também o relógio do servidor.Por que não comparar com ==
Por que não comparar com ==
A comparação comum para no primeiro caractere diferente, e o tempo de resposta
vaza quantos caracteres estavam certos. Use
crypto.timingSafeEqual,
hmac.compare_digest ou hash_equals — as bibliotecas oficiais já fazem isso.Header não encontrado
Header não encontrado
Nomes de header não diferenciam maiúsculas de minúsculas. No Node eles chegam em
minúsculo (
req.headers["webhook-id"]); no PHP, em $_SERVER com o prefixo
HTTP_ (HTTP_WEBHOOK_ID).Testando seu endpoint
Para testar a verificação sem esperar um evento real, gere uma requisição assinada com o seusecret no terminal:
2xx. Altere um caractere do BODY depois de gerar a
assinatura, ou use um TS de uma hora atrás, e ele deve responder 401.