MTA próprio vs API de email: custo, controle e vendor lock-in
Comparação entre manter um MTA próprio e usar uma API de envio de email: custos reais, controle de entregabilidade, operação e como evitar vendor lock-in.
5 min de leitura
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

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.
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.
A URL do seu endpoint é, na prática, pública. Sem verificação, alguém pode:
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.
Cada requisição traz três headers:
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:
{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.
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");
}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")timingSafeEqual, compare_digest). Comparação comum vaza informação por tempo de resposta.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.
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:
200/204. Processe depois, de forma assíncrona.webhook-id como chave única:INSERT INTO webhook_eventos (webhook_id, tipo, payload, recebido_em)
VALUES ($1, $2, $3, now())
ON CONFLICT (webhook_id) DO NOTHING;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.2xx confirma. 4xx por assinatura inválida não deve ser reenviado pelo provedor indefinidamente; 5xx e timeout indicam "tente de novo".==.id.timestamp.corpo com corpo bruto.webhook-id como chave de idempotência.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.
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.
É 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.
Rejeite eventos com timestamp fora de uma janela curta, como cinco minutos, e registre os webhook-id já processados para descartar repetições.
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.
Comparação entre manter um MTA próprio e usar uma API de envio de email: custos reais, controle de entregabilidade, operação e como evitar vendor lock-in.
5 min de leitura
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
Por que separar email transacional e de marketing em streams, IPs e subdomínios diferentes, e como fazer isso sem quebrar autenticação e DMARC.
5 min de leitura