Appearance
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-idunique.
Zéro endpoint actif : aucune livraison (le paiement se termine normalement).
En-têtes HTTP
| En-tête | Description |
|---|---|
Content-Type | application/json |
x-epayment-timestamp | Epoch Unix en secondes au moment de l'envoi |
x-epayment-event-id | UUID de la livraison (idempotence retry) |
x-epayment-signature | sha256={hex} |
x-epayment-signature-previous | Présent ~24 h après rotation ; signature valide avec l'ancien secret |
Construction de la signature
- Lire le corps brut (
rawBody) en UTF-8 avantJSON.parse. - Construire la charge signée :
signed_payload = "{timestamp}.{rawBody}"oùtimestampest la valeur dex-epayment-timestamp(chaîne décimale). - Calculer
HMAC-SHA256(webhook_secret, signed_payload)→ digest hexadécimal. - Comparer au suffixe de
x-epayment-signatureaprès le préfixesha256=. - Comparaison en temps constant (
timingSafeEqual). - Si
x-epayment-signature-previousest 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
| Comportement | Détail |
|---|---|
| Succès | HTTP 2xx sous 10 secondes |
| Idempotence | Même x-epayment-event-id peut revenir ; traitement sans double effet |
| Retry plateforme | Jusqu'à 5 tentatives (1 s, 5 s, 25 s, 125 s) |
| Erreur métier déjà enregistrée | Préférer 200 si l'événement est déjà traité |
| Panne temporaire réelle | 5xx pour déclencher un retry |
Rotation du secret
Procédure côté plateforme (portail régie ou super-admin) :
POST …/webhook-endpoints/{id}/rotate-secret- Nouveau secret retourné une seule fois dans la réponse
- Fenêtre ~24 h : en-têtes
x-epayment-signature(nouveau) etx-epayment-signature-previous(ancien) coexistent - Mettre à jour le coffre de secrets (
EPAYMENT_WEBHOOK_SECRET+ conserver l'ancien en…_PREVIOUSle temps de la fenêtre) - 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
| Secret | Usage |
|---|---|
sk_test_* / sk_live_* | Appels API sortants SI → Epayment |
| Secret endpoint webhook | Vérification livraisons Epayment → SI |
Ne jamais utiliser la clé API comme secret webhook ni l'inverse.