Guide pratique
Vérifier les signatures de webhook
Vérifiez que les requêtes de webhook proviennent d’Emailit en contrôlant la signature HMAC-SHA256 et l’horodatage, avec du code pour Node.js, Python, PHP, Ruby, Go et Laravel.
Toute personne qui connaît l’URL de votre webhook peut lui envoyer des requêtes. Chaque requête d’Emailit est signée avec le secret de votre webhook : vérifier la signature prouve que la requête provient d’Emailit et n’a pas été modifiée en chemin. Le contrôle de l’horodatage empêche en outre le rejeu d’anciennes requêtes. Faites les deux avant d’agir sur un événement.
Fonctionnement de la signature
Chaque webhook a son propre secret : whsec_ suivi de 64 caractères hexadécimaux. Il vous est affiché une seule fois, lorsque vous créez le webhook ou réinitialisez le secret, et Récupérer un webhook le renvoie.
Pour chaque requête, Emailit :
- Prend l’horodatage Unix actuel en secondes, par exemple
1790846045, et l’envoie dansX-Emailit-Timestamp. - Construit la chaîne
{timestamp}.{raw body}: l’horodatage, un point, puis les octets exacts du corps de la requête. - Calcule le HMAC-SHA256 de cette chaîne, avec le secret complet,
whsec_compris, comme clé. - Envoie le résultat, encodé en hexadécimal minuscule, dans
X-Emailit-Signature.
Pour vérifier la requête, votre endpoint répète les étapes 2 et 3 avec le corps brut reçu et compare son résultat à l’en-tête au moyen d’une comparaison à temps constant. Il vérifie ensuite que l’horodatage est récent. Nous recommandons une tolérance de 5 minutes (300 secondes). Chaque nouvelle tentative est signée de nouveau avec un horodatage à jour : une fenêtre de 5 minutes ne rejette donc pas les nouvelles tentatives.
Vérifier les requêtes
Chaque exemple lit le corps brut, rejette avec 401 les requêtes dont la signature est absente, expirée ou incorrecte, et seulement ensuite analyse le tableau 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();
}
}Dans Laravel, les routes de routes/api.php ne sont pas soumises au contrôle des jetons CSRF et reçoivent le préfixe /api : l’URL ci-dessus est donc https://acme.com/api/webhooks/emailit. Si vous enregistrez la route dans routes/web.php, excluez le chemin de la vérification CSRF, sinon Laravel rejette les requêtes d’Emailit avec 419.
Tester votre vérification
Testez votre fonction avec cet exemple connu avant de déployer. Appelez directement la partie HMAC, car l’horodatage est dans le passé et échoue au contrôle des 5 minutes.
| Entrée | Valeur |
|---|---|
| Secret | whsec_4f2a9c1e7b3d5a8f0e6c2b9d4a7f1e3c5b8d0a2f6e9c3b7d1a5f8e2c4b6d9a0f |
| Horodatage | 1790846045 |
| Signature attendue | 7242b580f77ec488f8ac21b57f092e3db61a431694f13074c7b4c85a4dd04be3 |
Corps brut, exactement tel qu’affiché, sans saut de ligne final :
[{"event_id":"evt_2xGk7Nq1VbD5sR8tLmW3eYhC6aP","type":"email.delivered"}]Utilisez ensuite Send test sur le webhook pour confirmer que votre endpoint déployé accepte une vraie requête signée. Consultez Configurer un webhook.
Erreurs fréquentes
- Analyser avant de vérifier. Les frameworks qui analysent automatiquement le JSON (
express.json(),request.jsondans Flask, les params de Rails) vous donnent un objet, pas les octets signés par Emailit. Lisez le corps brut sur la route du webhook. - Resérialiser le JSON.
JSON.stringify(req.body)oujson.dumps(data)produit des espaces, un ordre de clés ou un échappement différents : le HMAC ne correspondra pas. Hachez toujours le corps exactement tel que vous l’avez reçu. - Modifier le secret. Utilisez la valeur complète,
whsec_compris, en texte brut. Ne retirez pas le préfixe et ne le décodez pas depuis l’hexadécimal ou le Base64. - Signer la mauvaise chaîne. L’entrée est l’horodatage, un point, puis le corps. Signer uniquement le corps, ou utiliser un autre séparateur, échoue.
- Comparer avec
==. Utilisez une fonction à temps constant commecrypto.timingSafeEqual,hmac.compare_digest,hash_equals,Rack::Utils.secure_compareouhmac.Equal. - Une horloge serveur inexacte. Si l’horloge de votre serveur dérive, des requêtes valides sortent de la tolérance. Synchronisez-la avec NTP.
- Un secret ancien ou qui ne correspond pas. Après Reset, l’ancien secret cesse immédiatement de fonctionner, y compris pour les nouvelles tentatives. Chaque webhook a son propre secret : deux endpoints ne peuvent pas en partager un.
- Un middleware qui réécrit le corps. Les proxys, les passerelles d’API ou la décompression du corps qui modifient les octets avant que votre code ne les voie cassent la signature. Vérifiez la signature au plus près de la requête brute.
Les requêtes dont la vérification échoue reçoivent un 401 de votre endpoint et font l’objet de nouvelles tentatives comme tout autre échec : si vous corrigez un secret mal configuré en quelques heures, vous ne perdez rien.