# 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.

Source: https://pidgely.com/pt-br/blog/webhooks-de-email-verificacao-de-assinatura/ · Published: 2026-10-12 · Pidgely

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;
```

3. **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.
4. **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.

