Chaque webhook envoyé par MED est signé. Vérifier cette signature est la seule façon de garantir qu’une requête reçue sur votre endpoint provient bien de My Express Driver — et non d’un tiers qui aurait deviné son URL. La vérification repose sur trois mécanismes complémentaires :

Signature HMAC

X-MED-Signature certifie l’authenticité et l’intégrité du corps reçu.

Anti-rejeu

X-MED-Timestamp permet de rejeter les requêtes trop anciennes (fenêtre 5 min).

Déduplication

X-MED-Delivery-Id identifie une livraison de façon stable pour ignorer les doublons.
Vérifiez la signature avant de faire confiance au contenu. Tant que la signature n’est pas validée, traitez le corps de la requête comme une donnée non fiable : ne le désérialisez pas pour déclencher une action métier, ne le journalisez pas comme un événement de confiance.

Les en-têtes de livraison

Chaque POST sortant vers votre endpoint porte ces quatre en-têtes :
En-têteRôle
X-MED-Event-TypeType d’événement (ex. transport.created).
X-MED-Delivery-IdIdentifiant stable de la livraison — clé de déduplication côté consommateur. Identique sur les retries.
X-MED-TimestampEpoch en secondes au moment de l’envoi — sert à l’anti-rejeu.
X-MED-Signaturesha256=<hex> — la signature HMAC à vérifier.
Le corps de la requête est toujours un JSON de la forme { id, type, created_at, data }.

Comment la signature est calculée

La valeur de l’en-tête X-MED-Signature est construite ainsi :
X-MED-Signature = "sha256=" + HMAC_SHA256("{X-MED-Timestamp}.{corps brut}", signing_secret)
Autrement dit :
1

Concaténer le payload signé

MED forme la chaîne {X-MED-Timestamp}.{corps brut} : la valeur de l’en-tête X-MED-Timestamp, un point littéral ., puis le corps brut de la requête exactement tel qu’il est transmis sur le réseau.
2

Calculer le HMAC

Un HMAC-SHA256 de cette chaîne est calculé avec votre signing secret (whsec_…), encodé en hexadécimal.
3

Préfixer

Le résultat est préfixé par sha256= et envoyé dans l’en-tête X-MED-Signature.
De votre côté, vous recalculez exactement la même valeur à partir du corps reçu et de votre signing secret, puis vous la comparez à temps constant à l’en-tête reçu.
Le signing secret (whsec_…) vous est montré une seule fois, à la création de l’endpoint webhook depuis votre profil client. Conservez-le dans un coffre de secrets : c’est la clé qui permet de vérifier toutes les signatures de cet endpoint.

Le corps brut est impératif

Le HMAC porte sur le corps brut (raw body), octet pour octet, tel que reçu. Si votre framework parse le JSON avant que vous ne calculiez la signature, vous re-sérialiserez ensuite un objet — espaces, ordre des clés et échappements peuvent différer de l’original, et la signature ne correspondra jamais.Concrètement, en Express : n’appliquez pas express.json() sur la route webhook avant la vérification. Utilisez express.raw({ type: "application/json" }) pour récupérer un Buffer non modifié, vérifiez la signature, puis seulement faites JSON.parse.

Les deux protections à ne pas oublier

Comparez X-MED-Timestamp (epoch en secondes) à l’heure courante. Si l’écart dépasse 300 secondes (5 minutes), rejetez la requête. Cela empêche un attaquant de capturer une requête signée valide et de la rejouer plus tard. Inclure le timestamp dans le payload signé garantit qu’il ne peut pas être falsifié sans invalider la signature.
La livraison est at-least-once : vous pouvez recevoir plusieurs fois le même événement (retry après un timeout réseau, par exemple). L’en-tête X-MED-Delivery-Id est stable sur tous les essais d’une même livraison. Stockez les IDs déjà traités et ignorez les doublons — tout en répondant quand même 2xx pour acquitter.

Exemple complet — Node.js / Express

L’exemple ci-dessous est complet et exécutable (Node.js ≥ 18). Il capture le corps brut, vérifie l’horodatage anti-rejeu, recalcule le HMAC, compare à temps constant, puis déduplique sur X-MED-Delivery-Id.
// Node.js >= 18, Express. Vérification d'un webhook MED.
const express = require("express");
const crypto = require("crypto");

const SIGNING_SECRET = process.env.MED_WEBHOOK_SECRET; // whsec_...
const MAX_SKEW_SECONDS = 300; // anti-rejeu : 5 minutes

const app = express();

// IMPORTANT : capturer le corps BRUT (Buffer), surtout PAS express.json()
// avant la vérification — sinon la signature ne correspondra jamais.
app.use("/webhooks/med", express.raw({ type: "application/json" }));

function verifyMedSignature(req) {
  const signature = req.get("X-MED-Signature") || ""; // "sha256=..."
  const timestamp = req.get("X-MED-Timestamp") || "";
  const rawBody = req.body; // Buffer (grâce à express.raw)

  // 1. Anti-rejeu : refuser un horodatage absent, invalide ou trop ancien.
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;
  const skew = Math.abs(Math.floor(Date.now() / 1000) - ts);
  if (skew > MAX_SKEW_SECONDS) return false;

  // 2. Recalculer le HMAC sur "{timestamp}.{corps brut}".
  const signedPayload = `${timestamp}.${rawBody.toString("utf8")}`;
  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", SIGNING_SECRET)
      .update(signedPayload)
      .digest("hex");

  // 3. Comparaison à temps constant (anti timing-attack).
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhooks/med", (req, res) => {
  // Vérifier AVANT de faire quoi que ce soit du corps.
  if (!verifyMedSignature(req)) {
    return res.status(401).send("invalid signature");
  }

  const deliveryId = req.get("X-MED-Delivery-Id");

  // 4. Déduplication : ignorer une livraison déjà traitée.
  if (alreadyProcessed(deliveryId)) {
    return res.status(200).send("ok"); // acquitter quand même
  }

  // Le corps n'est désérialisé qu'APRÈS validation de la signature.
  const event = JSON.parse(req.body.toString("utf8")); // { id, type, created_at, data }

  // 5. Traiter selon le type d'événement.
  switch (event.type) {
    case "transport.created":
      // event.data = { id, transport_id, status, price }
      break;
    case "transport.status_changed":
      // event.data = { id, from, to }
      break;
    case "document.added":
      // event.data = { id, document_key, url }
      break;
    // ... autres types
    default:
      break;
  }

  markProcessed(deliveryId);

  // 6. Répondre 2xx RAPIDEMENT pour acquitter ; le travail lourd part en async.
  return res.status(200).send("ok");
});

// À remplacer par votre store de déduplication (Redis, table SQL, etc.).
function alreadyProcessed(_deliveryId) {
  return false;
}
function markProcessed(_deliveryId) {}

app.listen(3000, () => console.log("listening on :3000"));
Comparez toujours la signature avec une fonction à temps constant (crypto.timingSafeEqual), jamais avec ===. Une comparaison naïve fuit, par son temps d’exécution, le nombre d’octets corrects et facilite une attaque par chronométrage.

Liste de contrôle

  • Endpoint exposé en HTTPS uniquement.
  • Corps brut capturé (pas de express.json() avant la vérification).
  • Signature recalculée sur {X-MED-Timestamp}.{corps brut} avec le signing secret.
  • Comparaison à temps constant (timingSafeEqual).
  • Horodatage rejeté au-delà de 5 minutes (X-MED-Timestamp).
  • Déduplication sur X-MED-Delivery-Id.
  • Réponse 2xx rapide pour acquitter ; traitement lourd en asynchrone.
La livraison étant at-least-once, prévoyez un filet de réconciliation pour les événements jamais reçus (endpoint indisponible, désactivé par le circuit breaker, etc.) : rejouez le journal via GET /events?since=<ISO8601> et inspectez l’état des envois via GET /webhooks/deliveries.

Étapes suivantes

Vue d'ensemble des webhooks

Catalogue complet des événements, en-têtes de livraison, retries et circuit breaker.

Réconciliation

Rattraper les événements manqués avec GET /events et GET /webhooks/deliveries.