Solução de problemas
Por que a assinatura do meu webhook não confere?
Resolva falhas na verificação de assinatura de webhooks. Assine o corpo bruto com o segredo whsec_ completo e o valor de X-Emailit-Timestamp.
Este artigo ajuda quando o seu endpoint rejeita os webhooks do Emailit porque a assinatura que você calcula não é igual ao cabeçalho X-Emailit-Signature. Em quase todos os casos, os bytes que você assina ou o segredo que você usa são diferentes dos que o Emailit usou.
Sintomas
- O seu handler retorna
401ou400com “invalid signature” para todos os webhooks, incluindo os eventos de Send test do painel. - As requisições na aba Requests do webhook mostram tentativas com falha, com o código de status de erro do seu endpoint.
- A verificação funciona em um teste unitário com um payload feito à mão, mas falha nas entregas reais.
Causa
O Emailit assina cada requisição assim:
signature = hex( HMAC-SHA256( secret, "<X-Emailit-Timestamp>.<raw request body>" ) )A assinatura falha quando uma das três entradas é diferente:
- O corpo foi serializado de novo. Os frameworks muitas vezes interpretam o JSON antes de o seu handler rodar. Chamar
JSON.stringify(req.body)produz espaços e uma ordem de chaves diferentes dos bytes que o Emailit enviou. O corpo é um array JSON de até 100 eventos, então um código que espera um objeto também pode alterar o formato dele. - O segredo está errado. Use o valor completo, incluindo o prefixo
whsec_. Cada webhook tem o próprio segredo, e redefini-lo invalida o antigo na hora, inclusive para as novas tentativas pendentes. - O timestamp está ausente ou é diferente. Use o valor exato do cabeçalho
X-Emailit-Timestampe junte-o ao corpo com um único ponto. - A comparação está errada. O cabeçalho está em hexadecimal minúsculo, não em Base64, e não tem o prefixo
sha256=. - Algo na frente da sua aplicação alterou o corpo, como um proxy que o recodifica ou o descompacta.
Solução
-
Leia o corpo bruto. Capture os bytes antes que qualquer parser JSON seja executado. No Express, monte
express.raw()apenas na rota do webhook:import crypto from 'node:crypto'; app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), (req, res) => { const timestamp = req.get('X-Emailit-Timestamp'); const expected = crypto .createHmac('sha256', process.env.EMAILIT_WEBHOOK_SECRET) // whsec_... .update(`${timestamp}.${req.body.toString('utf8')}`) .digest('hex'); const received = req.get('X-Emailit-Signature') ?? ''; const valid = received.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)); if (!valid) return res.sendStatus(401); const events = JSON.parse(req.body); // an array of events res.sendStatus(200); });Nos route handlers do Next.js, use
await request.text(). No Laravel, use$request->getContent(). No Flask, userequest.get_data(). -
Obtenha o segredo correto. Chame Obter um webhook, que retorna o
secretatual. No painel, o segredo é mostrado uma única vez, então, se você o perdeu, abra o webhook, escolha Webhook secret para redefini-lo e atualize a sua variável de ambiente imediatamente. -
Envie um evento de teste. Na página do webhook, escolha Send test e selecione um tipo de evento. O teste é assinado com o mesmo segredo, e a caixa de diálogo mostra o código de status e o corpo da resposta do seu endpoint.
-
Confira a tolerância do seu relógio. Se você rejeitar timestamps antigos para evitar ataques de replay, permita alguns minutos de diferença e mantenha o relógio do servidor sincronizado. Cada tentativa de entrega, incluindo as novas tentativas, traz um timestamp novo.
-
Tente de novo o que falhou. Quando a verificação funcionar, escolha Retry failed para reenviar as entregas com falha dos últimos 7 dias.
Para código em outras linguagens, consulte Assinatura das requisições de webhook. Para o formato do corpo, consulte Requisições de webhook.
Ainda com problemas?
Fale com o suporte ou pergunte no Discord. Informe o ID do webhook (wh_…) e o ID de um evento que falhou, nunca o segredo.