# 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](/fr/docs/api-reference/webhooks/get/) le renvoie.

Pour chaque requête, Emailit :

1. Prend l’horodatage Unix actuel en secondes, par exemple `1790846045`, et l’envoie dans `X-Emailit-Timestamp`.
2. Construit la chaîne `{timestamp}.{raw body}` : l’horodatage, un point, puis les octets exacts du corps de la requête.
3. Calcule le HMAC-SHA256 de cette chaîne, avec le secret complet, `whsec_` compris, comme clé.
4. 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.

**Node.js**

```javascript title="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);
```

**Python**

```python title="app.py"
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**

```php title="webhook.php"
<?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);
```

**Ruby**

```ruby title="app.rb"
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
end
```

**Go**

```go title="main.go"
package 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)
}
```

**Laravel**

```php title="app/Http/Controllers/EmailitWebhookController.php"
<?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 :

```json
[{"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](/fr/docs/webhooks/set-up/#send-a-test-event).

## Erreurs fréquentes

- **Analyser avant de vérifier.** Les frameworks qui analysent automatiquement le JSON (`express.json()`, `request.json` dans 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)` ou `json.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 comme `crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`, `Rack::Utils.secure_compare` ou `hmac.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](/fr/docs/webhooks/retries-and-failures/) comme tout autre échec : si vous corrigez un secret mal configuré en quelques heures, vous ne perdez rien.

## Voir aussi

  - [Requêtes de webhook](/fr/docs/webhooks/webhook-requests/)
  - [Nouvelles tentatives et échecs](/fr/docs/webhooks/retries-and-failures/)

---
Source: https://emailit.com/fr/docs/webhooks/request-signature/
