Pular para o conteúdo
Docs

Guia prático

Verificar assinaturas de webhook

Confirme que as requisições de webhook vêm do Emailit verificando a assinatura HMAC-SHA256 e o timestamp, com código para Node.js, Python, PHP, Ruby, Go e Laravel.

Atualizado em 1 de out. de 2026

Qualquer pessoa que conheça a URL do seu webhook pode enviar requisições para ela. Toda requisição do Emailit é assinada com o segredo do seu webhook, então verificar a assinatura prova que a requisição veio do Emailit e não foi alterada no caminho. Verificar o timestamp também impede que requisições antigas sejam reenviadas (ataque de replay). Faça as duas verificações antes de agir sobre qualquer evento.

Como a assinatura funciona

Cada webhook tem o próprio segredo: whsec_ seguido de 64 caracteres hexadecimais. Você o vê uma única vez, ao criar o webhook ou redefinir o segredo, e Obter um webhook o retorna.

Para cada requisição, o Emailit:

  1. Pega o horário Unix atual em segundos, por exemplo 1790846045, e o envia em X-Emailit-Timestamp.
  2. Monta a string {timestamp}.{raw body}: o timestamp, um ponto e os bytes exatos do corpo da requisição.
  3. Calcula o HMAC-SHA256 dessa string, usando como chave o segredo completo, incluindo whsec_.
  4. Envia o resultado, codificado em hexadecimal minúsculo, em X-Emailit-Signature.

Para verificar, o seu endpoint repete os passos 2 e 3 com o corpo bruto que recebeu e compara o resultado com o cabeçalho usando uma comparação em tempo constante. Depois, confirma que o timestamp é recente. Recomendamos uma tolerância de 5 minutos (300 segundos). Cada nova tentativa é assinada de novo com um timestamp novo, então uma janela de 5 minutos não rejeita novas tentativas.

Verificar as requisições

Cada exemplo lê o corpo bruto, rejeita com 401 as requisições com uma assinatura ausente, antiga ou incorreta e só então interpreta o array JSON.

server.js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.EMAILIT_WEBHOOK_SECRET; // full value, including whsec_
const TOLERANCE_SECONDS = 300;

export function verifyEmailitSignature(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!(age <= TOLERANCE_SECONDS)) return false; // also rejects non-numeric timestamps

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.`)
    .update(rawBody) // a Buffer with the exact bytes received
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw() keeps the body as a Buffer. Don't run express.json() on this route.
app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), (req, res) => {
  const valid = verifyEmailitSignature(
    req.body,
    req.get('X-Emailit-Signature'),
    req.get('X-Emailit-Timestamp'),
  );
  if (!valid) return res.status(401).send('Invalid signature');

  const events = JSON.parse(req.body.toString('utf8'));
  // Queue the events for processing, then acknowledge.
  res.sendStatus(204);
});

app.listen(3000);

No Laravel, as rotas em routes/api.php não passam pela verificação de tokens CSRF e recebem o prefixo /api, então a URL acima é https://acme.com/api/webhooks/emailit. Se você registrar a rota em routes/web.php, exclua o caminho da verificação de CSRF; caso contrário, o Laravel rejeita as requisições do Emailit com 419.

Testar a sua verificação

Teste a sua função com este exemplo conhecido antes de fazer deploy. Chame diretamente a parte do HMAC, porque o timestamp está no passado e não passa na verificação de 5 minutos.

Entrada Valor
Segredo whsec_4f2a9c1e7b3d5a8f0e6c2b9d4a7f1e3c5b8d0a2f6e9c3b7d1a5f8e2c4b6d9a0f
Timestamp 1790846045
Assinatura esperada 7242b580f77ec488f8ac21b57f092e3db61a431694f13074c7b4c85a4dd04be3

Corpo bruto, exatamente como mostrado, sem quebra de linha no final:

JSON
[{"event_id":"evt_2xGk7Nq1VbD5sR8tLmW3eYhC6aP","type":"email.delivered"}]

Depois, use Send test no webhook para confirmar que o seu endpoint, já com o deploy feito, aceita uma requisição assinada real. Consulte Configurar um webhook.

Erros comuns

  • Interpretar antes de verificar. Frameworks que interpretam o JSON automaticamente (express.json(), request.json no Flask, os params do Rails) entregam um objeto, não os bytes que o Emailit assinou. Leia o corpo bruto na rota do webhook.
  • Serializar o JSON de novo. JSON.stringify(req.body) ou json.dumps(data) produzem espaços em branco, ordem de chaves ou escapes diferentes, e o HMAC não confere. Sempre calcule o hash do corpo exatamente como foi recebido.
  • Alterar o segredo. Use o valor inteiro, incluindo whsec_, como texto simples. Não remova o prefixo nem o decodifique de hexadecimal ou Base64.
  • Assinar a string errada. A entrada é o timestamp, um ponto e depois o corpo. Assinar só o corpo, ou juntar as partes com outro separador, falha.
  • Usar == para comparar. Use uma função de tempo constante, como crypto.timingSafeEqual, hmac.compare_digest, hash_equals, Rack::Utils.secure_compare ou hmac.Equal.
  • Relógio do servidor errado. Se o relógio do seu servidor se desviar, requisições válidas ficam fora da tolerância. Mantenha-o sincronizado com NTP.
  • Segredo antigo ou trocado. Depois de Reset, o segredo antigo deixa de funcionar imediatamente, inclusive para as novas tentativas. Cada webhook tem o próprio segredo, então dois endpoints não podem compartilhar um.
  • Middleware que reescreve o corpo. Proxies, gateways de API ou a descompressão do corpo que alteram os bytes antes de o seu código vê-los quebram a assinatura. Verifique o mais perto possível da requisição bruta.

As requisições que falham na verificação recebem 401 do seu endpoint e ganham novas tentativas como qualquer outra falha, então corrigir um segredo mal configurado em poucas horas não causa nenhuma perda.

Esta página foi útil?

Obrigado pelo feedback.

Obrigado, lemos todas as mensagens.