Handler de webhook avec Express
Un endpoint qui résiste aux nouvelles tentatives, aux répétitions, aux tâches lentes et aux corps mal formés.
Un endpoint de webhook semble être la plus petite route de votre application, mais c'est souvent celle qui casse en production. Une machine l'appelle, une réponse lente déclenche une nouvelle tentative, le même événement peut l'appeler deux fois et son URL publique accepte les requêtes de tout le monde.
Cette recette construit avec Express un endpoint qui gère ces quatre contraintes. Le raisonnement se trouve dans Accepter un paiement, guide complet. Vous trouverez ici son implémentation sur un serveur fonctionnel.
Les quatre exigences à respecter
| Exigence | Problème en cas d'absence |
|---|---|
| Vérifier la signature | Toute personne qui trouve l'URL peut marquer des commandes comme payées |
| Répondre en moins de dix secondes | La livraison est marquée comme échouée et tentée cinq nouvelles fois |
| Dédupliquer | Le même événement livré deux fois entraîne une double livraison de la commande |
| Ne jamais produire d'exception | Un seul payload mal formé arrête tout l'endpoint |
1. Configuration
npm install express @wajub/nodeLa seule configuration nécessaire est le secret de signature de l'endpoint enregistré dans le Dashboard. Il commence par whsec_ en mode live et par whsec_test_ dans la sandbox. Ces deux valeurs sont différentes.
import { Wajub } from '@wajub/node';
export const wajub = new Wajub({
apiKey: process.env.WAJUB_API_KEY!,
webhookSecret: process.env.WAJUB_WEBHOOK_SECRET!,
});2. Le corps brut avant toute autre opération
La vérification de signature utilise exactement les octets signés par Wajub. Un analyseur JSON ne réordonne rien, mais sérialise à nouveau les données. Cela suffit à modifier le hash.
Configurez donc express.raw() sur la route du webhook, puis express.json() sur toutes les autres, dans cet ordre. Une erreur produit un endpoint apparemment correct qui refuse toutes les livraisons authentiques.
import express from 'express';
import { webhookRouter } from './webhooks';
const app = express();
app.use('/webhooks/wajub', express.raw({ type: '*/*' }), webhookRouter);
app.use(express.json());
app.listen(3001, () => console.log('listening on 3001'));3. Le handler
Trois actions sont effectuées dans un ordre essentiel : vérifier, confirmer la réception, puis traiter.
import { Router } from 'express';
import { wajub } from './wajub';
import { events } from './events';
import { queue } from './queue';
export const webhookRouter = Router();
webhookRouter.post('/', async (req, res) => {
let event;
try {
event = wajub.webhooks.constructEvent(
req.body,
req.headers['x-wajub-signature'] as string,
req.headers['x-wajub-timestamp'] as string,
);
} catch {
return res.sendStatus(400);
}
res.sendStatus(200);
try {
const isNew = await events.record(event.id, event.event);
if (!isNew) return;
await queue.add('wajub-event', event);
} catch (err) {
console.error('failed to enqueue', event.id, err);
}
});constructEvent effectue trois vérifications : il recalcule le HMAC de timestamp.body, le compare en temps constant et refuse un horodatage qui s'écarte de plus de 300 secondes de l'heure actuelle. Cette dernière vérification empêche la répétition future d'une requête interceptée sur le réseau.
res.sendStatus(200) se trouve volontairement avant le traitement. Wajub sait déjà que la livraison est arrivée. Aucun événement ultérieur ne peut donc déclencher une nouvelle tentative.
Après dix secondes, la livraison est considérée comme échouée
Une livraison dispose de dix secondes pour renvoyer un code 2xx. Ensuite, elle est tentée cinq nouvelles fois après trente secondes, une minute, cinq minutes, dix minutes et une heure. Si votre handler communique avec un prestataire de paiement, envoie un e-mail ou génère un PDF, placez ce travail dans la file d'attente, pas dans la requête.
4. Une déduplication qui résiste au redémarrage
Wajub relance une livraison échouée avec le même événement. Votre handler s'exécute donc deux fois pour la même opération. Il doit être capable de le détecter.
Effectuez la déduplication dans votre base de données, pas en mémoire. Un Set du processus est vide après chaque déploiement, précisément au moment où un retard de nouvelles tentatives arrive.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);La clé primaire assure la protection. Commencez par insérer et laissez la base de données refuser le doublon. Une vérification suivie d'une insertion crée une course en cas de livraisons simultanées.
import { db } from './db';
export const events = {
async record(eventId: string, eventType: string): Promise<boolean> {
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, event_type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING`,
[eventId, eventType],
);
return rowCount === 1;
},
};rowCount === 1 signifie que l'événement est nouveau et doit être traité. Zéro signifie qu'il a déjà été reçu. La bonne réponse consiste alors à terminer silencieusement.
Deux identifiants qui répondent à des questions différentes
event.id dans le corps identifie l'événement, c'est-à-dire ce qui s'est produit. L'en-tête X-Wajub-Delivery-Id identifie la tentative de livraison de cet événement vers votre endpoint. Les deux restent stables pendant les nouvelles tentatives. Dédupliquez avec event.id et enregistrez aussi l'identifiant de livraison afin que le support distingue une nouvelle tentative d'un véritable second événement.
5. Router l'événement
Une fois la requête terminée, le worker interprète l'événement. L'enveloppe est fixe : event contient le nom et data l'objet concerné.
Selon l'événement, data.id correspond donc à l'identifiant du paiement, du remboursement ou du transfert. Un échec contient aussi data.failure_reason, un diagnostic fourni par le prestataire et non une énumération fixe.
import { wajub } from './wajub';
import { orders } from './orders';
export async function handleEvent(event: any) {
switch (event.event) {
case 'payment.succeeded':
return fulfillPayment(event.data.id);
case 'payment.failed':
case 'payment.expired':
case 'payment.cancelled':
return orders.markFailed(event.data.id, event.data.failure_reason);
case 'refund.succeeded':
return orders.markRefunded(event.data.transaction, event.data.amount);
case 'refund.failed':
return support.reopen(event.data.transaction, event.data.failure_reason);
case 'transfer.succeeded':
return payouts.markComplete(event.data.id);
default:
console.log('unhandled event', event.event);
}
}
async function fulfillPayment(paymentId: string) {
const payment = await wajub.payments.retrieve(paymentId);
if (payment.status !== 'succeeded') return;
const order = await orders.findByPaymentId(paymentId);
if (!order || Number(payment.amount) !== Number(order.amount)) return;
if (await orders.claim(order.id)) await deliver(order);
}Notez que les branches de remboursement lisent data.transaction, pas data.payment. Un remboursement désigne son paiement dans transaction. C'est l'un des rares champs dont le nom n'est pas évident.
Notez aussi que fulfillPayment relit le paiement depuis l'API. Le corps de l'événement est signé et fiable, mais cette nouvelle lecture couvre le cas où une valeur change entre l'émission de l'événement et son traitement par votre worker.
6. Si votre stack ne possède aucun SDK
constructEvent existe pour Node, PHP, Python, Go et C#. Si vous utilisez un autre langage, voici l'implémentation complète de la même vérification.
import crypto from 'crypto';
const TOLERANCE_SECONDS = 300;
export function verify(rawBody: Buffer, signature: string, timestamp: string, secret: string) {
if (!signature?.startsWith('v1=')) return false;
const drift = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(drift) || drift > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody.toString('utf8')}`)
.digest('hex');
const received = signature.slice(3);
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}`timingSafeEqual` produit une exception si les longueurs diffèrent
Lorsque les deux buffers n'ont pas la même longueur, cette fonction produit une RangeError au lieu de renvoyer false. Une signature mal formée provoque alors une erreur 500 au lieu d'une erreur 400. La vérification explicite de la longueur ci-dessus est obligatoire et ne divulgue rien, car la longueur d'un condensé hexadécimal est publique.
Quatre en-têtes interviennent.
| En-tête | Contenu |
|---|---|
X-Wajub-Signature | v1= suivi du condensé hexadécimal HMAC-SHA256 |
X-Wajub-Timestamp | La seconde Unix de signature de l'événement |
X-Wajub-Event | Le nom de l'événement, également présent dans le corps |
X-Wajub-Delivery-Id | Cette tentative de livraison, stable pendant les nouvelles tentatives |
La page Vérification de signature fait autorité si l'algorithme change.
7. Tester avec de véritables livraisons
Aucun déploiement n'est nécessaire. La CLI transfère les événements de votre compte vers un port local.
wajub listen --forward-to localhost:3001/webhooks/wajub
wajub trigger payment.succeededwajub trigger émet un véritable événement sur votre compte de sandbox. Toute la suite est donc réelle : signature, event.id et nouvelle tentative si vous répondez trop lentement.
Vérifiez trois éléments avant le déploiement.
| Test | Méthode | Résultat attendu |
|---|---|---|
| Signature incorrecte | Envoyer curl -X POST vers l'endpoint sans en-têtes | Réponse 400, aucun enregistrement |
| Livraison en double | Renvoyer le même événement depuis Konsole | La seconde livraison n'insère et n'effectue rien |
| Traitement lent | Ajouter une attente de dix secondes dans le worker | La réponse reste immédiate |
Pages associées
- Vérification de signatureL'algorithme de référence et ses paramètres.
- Catalogue des événementsChaque événement envoyé par Wajub, produit par produit.
- Nouvelles tentatives et ordreLe calendrier des nouvelles tentatives et les conditions d'échec d'une livraison.
- Accepter un paiement, guide completLa livraison effectuée de l'autre côté de cette file d'attente.
- wajub listenTransférer les événements live vers un port de votre machine.