Skip to content

Sécurité des webhooks ​

Epayment signe chaque livraison webhook avec HMAC-SHA256. Le SI ministry vérifie la signature avant tout parsing métier ou effet de bord.

Modèle ​

  • Une application possède zéro à N endpoints webhook (application_webhook_endpoints).
  • Chaque endpoint a son propre secret (webhook:endpoint:signing:{endpointId}).
  • Chaque événement métier génère une livraison avec un x-epayment-event-id unique.

Zéro endpoint actif : aucune livraison (le paiement se termine normalement).

En-têtes HTTP ​

En-têteDescription
Content-Typeapplication/json
x-epayment-timestampEpoch Unix en secondes au moment de l'envoi
x-epayment-event-idUUID de la livraison (idempotence retry)
x-epayment-signaturesha256={hex}
x-epayment-signature-previousPrésent ~24 h après rotation ; signature valide avec l'ancien secret

Construction de la signature ​

  1. Lire le corps brut (rawBody) en UTF-8 avant JSON.parse.
  2. Construire la charge signée : signed_payload = "{timestamp}.{rawBody}" où timestamp est la valeur de x-epayment-timestamp (chaîne décimale).
  3. Calculer HMAC-SHA256(webhook_secret, signed_payload) → digest hexadécimal.
  4. Comparer au suffixe de x-epayment-signature après le préfixe sha256=.
  5. Comparaison en temps constant (timingSafeEqual).
  6. Si x-epayment-signature-previous est présent, répéter avec l'ancien secret ; accepter si l'une des deux signatures est valide.

Le secret webhook est une chaîne base64url (~43 caractères), distincte de la clé API sk_.

Exemple Node.js (Express) ​

javascript
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

function verifySignature(rawBody, headers, secret) {
  const timestamp = headers['x-epayment-timestamp'];
  const signature = headers['x-epayment-signature'];
  if (!timestamp || !signature?.startsWith('sha256=')) {
    return false;
  }
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('hex');
  const received = signature.slice('sha256='.length);
  if (expected.length !== received.length) {
    return false;
  }
  return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

function verifyWithRotation(rawBody, headers, currentSecret, previousSecret) {
  if (verifySignature(rawBody, headers, currentSecret)) {
    return true;
  }
  const previousSig = headers['x-epayment-signature-previous'];
  if (previousSecret && previousSig?.startsWith('sha256=')) {
    const headersWithPrevious = {
      ...headers,
      'x-epayment-signature': previousSig,
    };
    return verifySignature(rawBody, headersWithPrevious, previousSecret);
  }
  return false;
}

const app = express();

app.post('/webhooks/epayment', express.raw({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body.toString('utf8');
  const ok = verifyWithRotation(
    rawBody,
    req.headers,
    process.env.EPAYMENT_WEBHOOK_SECRET,
    process.env.EPAYMENT_WEBHOOK_SECRET_PREVIOUS,
  );
  if (!ok) {
    return res.status(401).send('invalid signature');
  }

  const event = JSON.parse(rawBody);

  if (event.event === 'checkout.completed' && event.paid === true) {
    await unlockDossierIdempotent(event.order_id);
  }

  return res.status(200).json({ ok: true });
});

Points critiques :

  • Middleware express.raw (ou équivalent) pour conserver l'octet exact du corps.
  • Ne pas recalculer la signature sur un objet re-sérialisé après parse.
  • Rejeter (401) si aucune signature ne correspond ; ne pas exécuter d'effet métier.

Réponse attendue ​

ComportementDétail
SuccèsHTTP 2xx sous 10 secondes
IdempotenceMême x-epayment-event-id peut revenir ; traitement sans double effet
Retry plateformeJusqu'à 5 tentatives (1 s, 5 s, 25 s, 125 s)
Erreur métier déjà enregistréePréférer 200 si l'événement est déjà traité
Panne temporaire réelle5xx pour déclencher un retry

Rotation du secret ​

Procédure côté plateforme (portail régie ou super-admin) :

  1. POST …/webhook-endpoints/{id}/rotate-secret
  2. Nouveau secret retourné une seule fois dans la réponse
  3. Fenêtre ~24 h : en-têtes x-epayment-signature (nouveau) et x-epayment-signature-previous (ancien) coexistent
  4. Mettre à jour le coffre de secrets (EPAYMENT_WEBHOOK_SECRET + conserver l'ancien en …_PREVIOUS le temps de la fenêtre)
  5. Retirer l'ancien secret après disparition de x-epayment-signature-previous

Validation des payloads ​

Après vérification HMAC, valider le JSON contre les schémas ministry :

  • Payload checkout.completed
  • Payload terminal pour checkout.failed, checkout.cancelled, checkout.expired

Champs communs terminaux : session_id, order_id, notice_id, paid, metadata optionnel, service_code / service_label optionnels. checkout.failed ajoute failure_code.

Séparation des secrets ​

SecretUsage
sk_test_* / sk_live_*Appels API sortants SI → Epayment
Secret endpoint webhookVérification livraisons Epayment → SI

Ne jamais utiliser la clé API comme secret webhook ni l'inverse.

Plateforme d'orchestration de paiement DPI. Les fonds ne transitent pas par Epayment.