Pular para o conteúdo
Pidgely
  • webhooks
  • infraestrutura
  • boas-praticas

Webhooks de email: como verificar a assinatura com segurança

Como receber webhooks de eventos de email com segurança: verificação de assinatura no padrão Standard Webhooks, proteção contra replay, retries e idempotência.

Time PidgelyPublicado em 5 min de leituraVersão em Markdown

Neste artigo
  1. Para que servem os webhooks de email
  2. Por que a assinatura é obrigatória
  3. O padrão Standard Webhooks
  4. Implementação passo a passo
  5. Replay: janela de tempo e IDs processados
  6. Retentativas e idempotência
  7. Operação
  8. Erros comuns
  9. Checklist
  10. Na Pidgely
  11. Perguntas frequentes

Para receber webhooks de eventos de email com segurança, verifique a assinatura HMAC de cada requisição antes de processá-la, rejeite timestamps fora de uma janela curta para evitar replay e trate cada evento de forma idempotente usando o ID da mensagem do webhook, porque o mesmo evento pode chegar mais de uma vez. O padrão Standard Webhooks define exatamente como fazer isso com três headers (webhook-id, webhook-timestamp e webhook-signature) e uma assinatura HMAC-SHA256, o que permite usar bibliotecas prontas. Abaixo está a implementação completa e os cuidados de operação.

Para que servem os webhooks de email

Enviar um email é assíncrono: a API responde que aceitou a mensagem, mas a entrega, o bounce, a reclamação ou o descadastro acontecem depois. Webhooks avisam sua aplicação desses eventos, por exemplo:

  • email.delivered: o servidor de destino aceitou.
  • email.bounced: falha permanente ou temporária, com código SMTP.
  • email.complained: o destinatário marcou como spam (via feedback loop).
  • email.unsubscribed: descadastro, inclusive de um clique.
  • email.deferred: tentativa adiada.

Com isso a aplicação atualiza o status do contato, mostra ao usuário que o convite não chegou, ou para de enviar para quem reclamou.

Por que a assinatura é obrigatória

A URL do seu endpoint é, na prática, pública. Sem verificação, alguém pode:

  • forjar eventos de bounce e fazer você suprimir clientes válidos;
  • forjar descadastros;
  • reenviar um evento antigo legítimo (replay) para bagunçar estados;
  • inundar o endpoint com payloads grandes.

A assinatura HMAC com um segredo compartilhado prova duas coisas: que o evento veio de quem conhece o segredo e que o corpo não foi alterado no caminho.

O padrão Standard Webhooks

Cada requisição traz três headers:

HTTP
POST /webhooks/email HTTP/1.1
Content-Type: application/json
webhook-id: msg_2mYpQ8rT1vXk
webhook-timestamp: 1791810000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"type":"email.bounced","data":{"email_id":"em_91x","to":"joao@example.com.br","bounce":{"type":"hard","status":"5.1.1"}}}
  • webhook-id: identificador único da mensagem do webhook. Repetido em retentativas do mesmo evento.
  • webhook-timestamp: segundos desde a época Unix, no momento do envio.
  • webhook-signature: uma ou mais assinaturas separadas por espaço, cada uma no formato v1,<base64>. Mais de uma aparece durante a rotação de segredos.

O conteúdo assinado é a concatenação:

text
{webhook-id}.{webhook-timestamp}.{corpo bruto}

A assinatura é base64(HMAC-SHA256(segredo, conteúdo_assinado)). O segredo costuma vir com o prefixo whsec_ seguido do valor em base64; a parte depois do prefixo é decodificada para obter a chave.

Implementação passo a passo

Node.js

JavaScript
import crypto from "node:crypto";

const TOLERANCIA_SEGUNDOS = 5 * 60;

export function verificarWebhook(segredo, headers, corpoBruto) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const assinaturas = headers["webhook-signature"];
  if (!id || !timestamp || !assinaturas) throw new Error("headers ausentes");

  const agora = Math.floor(Date.now() / 1000);
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(agora - ts) > TOLERANCIA_SEGUNDOS) {
    throw new Error("timestamp fora da janela");
  }

  const chave = Buffer.from(segredo.replace(/^whsec_/, ""), "base64");
  const esperado = crypto
    .createHmac("sha256", chave)
    .update(`${id}.${timestamp}.${corpoBruto}`)
    .digest();

  const valida = assinaturas.split(" ").some((item) => {
    const [versao, valor] = item.split(",", 2);
    if (versao !== "v1" || !valor) return false;
    const recebido = Buffer.from(valor, "base64");
    return recebido.length === esperado.length &&
      crypto.timingSafeEqual(recebido, esperado);
  });
  if (!valida) throw new Error("assinatura inválida");
}

Python

Python
import base64, hashlib, hmac, time

def verificar_webhook(segredo: str, headers: dict, corpo_bruto: bytes) -> None:
    msg_id = headers["webhook-id"]
    ts = int(headers["webhook-timestamp"])
    if abs(time.time() - ts) > 300:
        raise ValueError("timestamp fora da janela")

    chave = base64.b64decode(segredo.removeprefix("whsec_"))
    conteudo = f"{msg_id}.{ts}.".encode() + corpo_bruto
    esperado = base64.b64encode(hmac.new(chave, conteudo, hashlib.sha256).digest()).decode()

    for item in headers["webhook-signature"].split(" "):
        versao, _, valor = item.partition(",")
        if versao == "v1" and hmac.compare_digest(valor, esperado):
            return
    raise ValueError("assinatura inválida")

Detalhes que quebram a verificação

  • Use o corpo bruto. Se o framework fizer parse do JSON e você serializar de novo, espaços e ordem de chaves mudam e a assinatura não bate. Capture os bytes exatos da requisição.
  • Compare em tempo constante (timingSafeEqual, compare_digest). Comparação comum vaza informação por tempo de resposta.
  • Aceite várias assinaturas. Durante a rotação de segredo, o header traz mais de uma; basta uma ser válida.
  • Relógio sincronizado. Servidor com relógio errado rejeita tudo por timestamp. Use NTP.

Replay: janela de tempo e IDs processados

A janela de timestamp (cinco minutos é o valor recomendado pela especificação) impede que um evento capturado seja reenviado dias depois. Dentro da janela, a proteção vem do registro de IDs: guarde cada webhook-id processado e descarte repetidos.

Retentativas e idempotência

Provedores de webhook entregam pelo menos uma vez. Se o seu endpoint não responder 2xx dentro do tempo limite, o evento é reenviado com backoff exponencial, às vezes por horas ou dias. Consequências práticas:

  1. Responda rápido. Valide a assinatura, grave o evento em uma fila ou tabela e responda 200/204. Processe depois, de forma assíncrona.
  2. Seja idempotente. Use o webhook-id como chave única:
SQL
INSERT INTO webhook_eventos (webhook_id, tipo, payload, recebido_em)
VALUES ($1, $2, $3, now())
ON CONFLICT (webhook_id) DO NOTHING;
  1. Não confie na ordem. Um email.delivered pode chegar depois de um email.complained da mesma mensagem. Use o timestamp do evento no payload para ordenar e não regredir estados.
  2. Códigos de resposta corretos. 2xx confirma. 4xx por assinatura inválida não deve ser reenviado pelo provedor indefinidamente; 5xx e timeout indicam "tente de novo".

Operação

  • Guarde o segredo como credencial, em um cofre de segredos, nunca no repositório.
  • Rotacione periodicamente, aproveitando o suporte a múltiplas assinaturas.
  • Use HTTPS sempre. A assinatura protege integridade e origem, não confidencialidade.
  • Monitore falhas: taxa de erro do endpoint, eventos atrasados e fila de processamento.
  • Tenha um caminho de reconciliação: se o endpoint ficou fora do ar por muito tempo, consulte a API de eventos para preencher lacunas.

Erros comuns

  • Verificar a assinatura sobre o JSON re-serializado.
  • Processar o evento de forma síncrona e estourar o tempo limite, gerando retentativas em cascata.
  • Ignorar o timestamp e aceitar eventos antigos.
  • Comparar assinaturas com ==.
  • Supor que cada evento chega exatamente uma vez e em ordem.

Checklist

  • [ ] Verificação HMAC-SHA256 sobre id.timestamp.corpo com corpo bruto.
  • [ ] Janela de timestamp de alguns minutos.
  • [ ] Comparação em tempo constante e suporte a múltiplas assinaturas.
  • [ ] webhook-id como chave de idempotência.
  • [ ] Resposta rápida e processamento assíncrono.
  • [ ] Segredo em cofre, com rotação.

Na Pidgely

Os webhooks da Pidgely seguem o padrão Standard Webhooks, então você pode usar as bibliotecas oficiais do padrão em várias linguagens para verificar a assinatura. Eventos de entrega, bounce, reclamação e descadastro são reenviados com backoff até a confirmação, sempre com o mesmo webhook-id.

Perguntas frequentes

Por que verificar a assinatura de um webhook?

Porque a URL do webhook é pública. Sem verificação, qualquer pessoa pode enviar eventos falsos, por exemplo marcando endereços como bounce ou descadastrados. A assinatura HMAC prova que o evento veio do provedor e não foi alterado.

O que é o padrão Standard Webhooks?

É uma especificação aberta que define como assinar e verificar webhooks: headers webhook-id, webhook-timestamp e webhook-signature, com assinatura HMAC-SHA256 sobre id, timestamp e corpo. Isso permite usar bibliotecas prontas em vez de código próprio para cada provedor.

Como evitar ataques de replay?

Rejeite eventos com timestamp fora de uma janela curta, como cinco minutos, e registre os webhook-id já processados para descartar repetições.

Por que recebi o mesmo evento duas vezes?

Webhooks são entregues pelo menos uma vez. Se sua resposta demorou, deu timeout ou retornou erro, o provedor tenta de novo. Por isso o processamento precisa ser idempotente, usando o webhook-id como chave.

Time Pidgely

Publicado em

Construímos o Pidgely, a API de email que leva você à caixa de entrada. Escrevemos sobre o que aprendemos entregando emails, citando os padrões e as diretrizes dos provedores por trás de cada afirmação.

Também disponível em:
entregabilidadeconformidade

Descadastro de um clique (RFC 8058): como implementar

Como implementar o descadastro de um clique com List-Unsubscribe e List-Unsubscribe-Post (RFC 8058), e como evitar descadastros falsos por scanners.

5 min de leitura