Aller au contenu

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

ExigenceProblème en cas d'absence
Vérifier la signatureToute personne qui trouve l'URL peut marquer des commandes comme payées
Répondre en moins de dix secondesLa livraison est marquée comme échouée et tentée cinq nouvelles fois
DédupliquerLe même événement livré deux fois entraîne une double livraison de la commande
Ne jamais produire d'exceptionUn seul payload mal formé arrête tout l'endpoint

1. Configuration

npm install express @wajub/node

La 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.

src/wajub.ts
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.

src/server.ts
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.

src/webhooks.ts
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.

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.

La table
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.

src/events.ts
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é.

Un événement payment.succeeded vérifié
{
"id": "evt_9Lq5RtVb2Kd8",
"event": "payment.succeeded",
"livemode": true,
"api_version": "2026-08-01",
"created": "2026-09-13T10:31:02+00:00",
"data": {
"id": "trx_CSUGajfv9xh0XQ5wu2lx",
"reference": "order-4172",
"amount": 5000,
"currency": "XAF",
"status": "succeeded",
"channel": "cm.mtn"
}
}

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.

src/worker.ts
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.

Vérification manuelle
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));
}

Quatre en-têtes interviennent.

En-têteContenu
X-Wajub-Signaturev1= suivi du condensé hexadécimal HMAC-SHA256
X-Wajub-TimestampLa seconde Unix de signature de l'événement
X-Wajub-EventLe nom de l'événement, également présent dans le corps
X-Wajub-Delivery-IdCette 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.

Deux terminaux
wajub listen --forward-to localhost:3001/webhooks/wajub

wajub trigger payment.succeeded

wajub 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.

TestMéthodeRésultat attendu
Signature incorrecteEnvoyer curl -X POST vers l'endpoint sans en-têtesRéponse 400, aucun enregistrement
Livraison en doubleRenvoyer le même événement depuis KonsoleLa seconde livraison n'insère et n'effectue rien
Traitement lentAjouter une attente de dix secondes dans le workerLa réponse reste immédiate

Que pensez-vous de ce contenu ?