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.
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:
- Pega o horário Unix atual em segundos, por exemplo
1790846045, e o envia emX-Emailit-Timestamp. - Monta a string
{timestamp}.{raw body}: o timestamp, um ponto e os bytes exatos do corpo da requisição. - Calcula o HMAC-SHA256 dessa string, usando como chave o segredo completo, incluindo
whsec_. - 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.
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);import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["EMAILIT_WEBHOOK_SECRET"].encode() # full value, including whsec_
TOLERANCE_SECONDS = 300
def verify_emailit_signature(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool:
if not signature or not timestamp:
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhooks/emailit")
def emailit_webhook():
raw_body = request.get_data() # raw bytes; don't use request.json first
if not verify_emailit_signature(
raw_body,
request.headers.get("X-Emailit-Signature"),
request.headers.get("X-Emailit-Timestamp"),
):
abort(401)
events = json.loads(raw_body)
# Queue the events for processing, then acknowledge.
return "", 204<?php
const TOLERANCE_SECONDS = 300;
function verifyEmailitSignature(string $rawBody, string $signature, string $timestamp, string $secret): bool
{
if ($signature === '' || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EMAILIT_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_EMAILIT_TIMESTAMP'] ?? '';
$secret = (string) getenv('EMAILIT_WEBHOOK_SECRET'); // full value, including whsec_
if (!verifyEmailitSignature($rawBody, $signature, $timestamp, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$events = json_decode($rawBody, true);
// Queue the events for processing, then acknowledge.
http_response_code(204);require 'sinatra'
require 'openssl'
require 'json'
require 'rack/utils'
SECRET = ENV.fetch('EMAILIT_WEBHOOK_SECRET') # full value, including whsec_
TOLERANCE_SECONDS = 300
def verify_emailit_signature(raw_body, signature, timestamp)
return false if signature.to_s.empty? || timestamp.to_s !~ /\A\d+\z/
return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, "#{timestamp}.#{raw_body}")
Rack::Utils.secure_compare(expected, signature)
end
post '/webhooks/emailit' do
raw_body = request.body.read # in Rails, use request.raw_post
valid = verify_emailit_signature(
raw_body,
request.env['HTTP_X_EMAILIT_SIGNATURE'],
request.env['HTTP_X_EMAILIT_TIMESTAMP']
)
halt 401, 'Invalid signature' unless valid
events = JSON.parse(raw_body)
# Queue the events for processing, then acknowledge.
status 204
endpackage main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"time"
)
const toleranceSeconds = 300
func verifyEmailitSignature(rawBody []byte, signature, timestamp, secret string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || signature == "" {
return false
}
age := time.Now().Unix() - ts
if age > toleranceSeconds || age < -toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
type Event struct {
EventID string `json:"event_id"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
func emailitWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
secret := os.Getenv("EMAILIT_WEBHOOK_SECRET") // full value, including whsec_
if !verifyEmailitSignature(rawBody, r.Header.Get("X-Emailit-Signature"), r.Header.Get("X-Emailit-Timestamp"), secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var events []Event
if err := json.Unmarshal(rawBody, &events); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// Queue the events for processing, then acknowledge.
w.WriteHeader(http.StatusNoContent)
}
func main() {
http.HandleFunc("/webhooks/emailit", emailitWebhook)
http.ListenAndServe(":8080", nil)
}<?php
// config/services.php: 'emailit' => ['webhook_secret' => env('EMAILIT_WEBHOOK_SECRET')],
// routes/api.php: Route::post('/webhooks/emailit', EmailitWebhookController::class);
namespace App\Http\Controllers;
use App\Jobs\ProcessEmailitEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class EmailitWebhookController extends Controller
{
private const TOLERANCE_SECONDS = 300;
public function __invoke(Request $request): Response
{
$rawBody = $request->getContent();
$signature = (string) $request->header('X-Emailit-Signature', '');
$timestamp = (string) $request->header('X-Emailit-Timestamp', '');
$secret = (string) config('services.emailit.webhook_secret'); // full value, including whsec_
$fresh = ctype_digit($timestamp)
&& abs(time() - (int) $timestamp) <= self::TOLERANCE_SECONDS;
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
abort_unless($fresh && hash_equals($expected, $signature), 401, 'Invalid signature');
foreach (json_decode($rawBody, true) as $event) {
ProcessEmailitEvent::dispatch($event); // a queued job
}
return response()->noContent();
}
}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:
[{"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.jsonno 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)oujson.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, comocrypto.timingSafeEqual,hmac.compare_digest,hash_equals,Rack::Utils.secure_compareouhmac.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.