Guida pratica
Verifica le firme dei webhook
Controlla che le richieste webhook arrivino da Emailit verificando la firma HMAC-SHA256 e il timestamp, con codice per Node.js, Python, PHP, Ruby, Go e Laravel.
Chiunque conosca l’URL del tuo webhook può inviargli richieste. Ogni richiesta di Emailit è firmata con il secret del webhook, quindi verificare la firma dimostra che la richiesta proviene da Emailit e non è stata modificata lungo il percorso. Controllare il timestamp impedisce inoltre che vecchie richieste vengano riprodotte (replay). Fai entrambe le cose prima di agire su qualsiasi evento.
Come funziona la firma
Ogni webhook ha il proprio secret: whsec_ seguito da 64 caratteri esadecimali. Lo vedi una sola volta, quando crei il webhook o reimposti il secret, e Recupera un webhook lo restituisce.
Per ogni richiesta, Emailit:
- Prende l’ora Unix attuale in secondi, ad esempio
1790846045, e la invia inX-Emailit-Timestamp. - Costruisce la stringa
{timestamp}.{raw body}: il timestamp, un punto e i byte esatti del corpo della richiesta. - Calcola l’HMAC-SHA256 di questa stringa, usando come chiave il secret completo, compreso
whsec_. - Invia il risultato, codificato in esadecimale minuscolo, in
X-Emailit-Signature.
Per verificare, il tuo endpoint ripete i passaggi 2 e 3 con il corpo grezzo ricevuto e confronta il risultato con l’header usando un confronto a tempo costante. Poi controlla che il timestamp sia recente. Ti consigliamo una tolleranza di 5 minuti (300 secondi). Ogni nuovo tentativo viene firmato di nuovo con un timestamp aggiornato, quindi una finestra di 5 minuti non rifiuta i nuovi tentativi.
Verifica le richieste
Ogni esempio legge il corpo grezzo, rifiuta con 401 le richieste con una firma mancante, scaduta o errata, e solo dopo analizza l’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();
}
}In Laravel, le route in routes/api.php non vengono controllate per i token CSRF e ricevono il prefisso /api, quindi l’URL dell’esempio è https://acme.com/api/webhooks/emailit. Se registri la route in routes/web.php, escludi il percorso dalla verifica CSRF, altrimenti Laravel rifiuta le richieste di Emailit con 419.
Testa la verifica
Prima del deploy, controlla la tua funzione con questo esempio noto. Chiama direttamente la parte HMAC, perché il timestamp è nel passato e non supera il controllo dei 5 minuti.
| Dato | Valore |
|---|---|
| Secret | whsec_4f2a9c1e7b3d5a8f0e6c2b9d4a7f1e3c5b8d0a2f6e9c3b7d1a5f8e2c4b6d9a0f |
| Timestamp | 1790846045 |
| Firma attesa | 7242b580f77ec488f8ac21b57f092e3db61a431694f13074c7b4c85a4dd04be3 |
Corpo grezzo, esattamente come mostrato, senza a capo finale:
[{"event_id":"evt_2xGk7Nq1VbD5sR8tLmW3eYhC6aP","type":"email.delivered"}]Poi usa Send test sul webhook per confermare che l’endpoint in produzione accetta una vera richiesta firmata. Vedi Configura un webhook.
Errori comuni
- Analizzare prima di verificare. I framework che analizzano il JSON in automatico (
express.json(),request.jsonin Flask, i params di Rails) ti danno un oggetto, non i byte che Emailit ha firmato. Leggi il corpo grezzo nella route del webhook. - Serializzare di nuovo il JSON.
JSON.stringify(req.body)ojson.dumps(data)producono spazi, ordine delle chiavi o escape diversi, quindi l’HMAC non corrisponde. Calcola sempre l’hash del corpo esattamente come lo hai ricevuto. - Modificare il secret. Usa il valore intero, compreso
whsec_, come testo semplice. Non togliere il prefisso e non decodificarlo da esadecimale o Base64. - Firmare la stringa sbagliata. L’input è il timestamp, un punto, poi il corpo. Se firmi solo il corpo, o unisci le parti con un altro separatore, la verifica non riesce.
- Usare
==per il confronto. Usa una funzione a tempo costante comecrypto.timingSafeEqual,hmac.compare_digest,hash_equals,Rack::Utils.secure_compareohmac.Equal. - Un orologio del server sbagliato. Se l’orologio del server si sfasa, le richieste valide cadono fuori dalla tolleranza. Tienilo sincronizzato con NTP.
- Un secret vecchio o non corrispondente. Dopo Reset, il vecchio secret smette subito di funzionare, anche per i nuovi tentativi. Ogni webhook ha il proprio secret, quindi due endpoint non possono condividerne uno.
- Middleware che riscrive il corpo. Proxy, API gateway o la decompressione del corpo che modificano i byte prima che il tuo codice li veda invalidano la firma. Verifica il più vicino possibile alla richiesta grezza.
Le richieste che non superano la verifica ricevono un 401 dal tuo endpoint e vengono ritentate come qualsiasi altro errore, quindi se correggi un secret configurato male entro poche ore non perdi nulla.