Aller au contenu principal
Ben Tiga Jean Vincent
Retour au blog
  • Paiement
  • FinTech
  • Architecture
  • API

Intégrer les API de paiement des agrégateurs sans se faire piéger

Mobile Money, cartes bancaires, agrégateurs multiples : le modèle unifié, l'idempotence et la réconciliation qui évitent les doubles débits en production.

Ben Tiga Jean Vincent4 min de lecture

Brancher un premier agrégateur de paiement prend une journée. Ce qui prend des semaines, c'est d'en brancher trois et de garantir qu'aucun client ne sera débité deux fois. Voici ce que j'applique depuis Djangi et les intégrations Mobile Money qui ont suivi.

Aucun agrégateur ne se comporte comme le précédent

C'est le point de départ. Pour un même geste métier (« encaisser 5 000 F »), les modèles divergent :

DimensionVariantes rencontrées
ConfirmationRéponse synchrone, webhook asynchrone, ou les deux
DéclenchementPush USSD sur le téléphone, redirection web, token de carte
IdentifiantRéférence fournie par toi, générée par eux, ou les deux
Statut finalPoussé par webhook, ou à aller chercher par polling
DélaiDeux secondes pour une carte, jusqu'à deux minutes pour un push USSD non confirmé

Écrire du code qui épouse ces différences, c'est accepter que chaque ajout d'agrégateur touche au cœur métier. La seule issue viable est de les normaliser derrière une interface.

Une interface, des adaptateurs

type PaymentIntent = {
  reference: string;      // notre identifiant, jamais le leur
  amountMinor: number;    // 5000 F CFA => 500000 si 2 décimales
  currency: "XAF";
  customer: { msisdn?: string; email?: string };
};
 
type PaymentStatus = "pending" | "succeeded" | "failed" | "expired";
 
interface PaymentProvider {
  readonly id: string;
  initiate(intent: PaymentIntent): Promise<{ providerRef: string; status: PaymentStatus }>;
  verifyWebhook(rawBody: Buffer, headers: Headers): { reference: string; status: PaymentStatus } | null;
  fetchStatus(reference: string): Promise<PaymentStatus>;
}

Trois règles tiennent dans cette interface :

  1. Notre référence fait foi. On génère un identifiant interne avant tout appel réseau. Si l'agrégateur renvoie une erreur de timeout, on sait toujours de quelle transaction on parle.
  2. fetchStatus est obligatoire, même quand le fournisseur promet des webhooks. Les webhooks se perdent.
  3. Les montants sont des entiers, dans la plus petite unité monétaire. Un float sur de l'argent finit toujours par coûter une réconciliation manuelle.

L'idempotence n'est pas optionnelle

Le scénario qui débite deux fois est banal : l'utilisateur tape deux fois sur « Payer », ou le réseau coupe avant la réponse et le client mobile réessaie.

La protection se met au niveau de la base, pas du code applicatif :

CREATE TABLE payment (
    reference      TEXT PRIMARY KEY,
    idempotency_key TEXT NOT NULL,
    provider_id    TEXT NOT NULL,
    amount_minor   BIGINT NOT NULL,
    status         TEXT NOT NULL DEFAULT 'pending',
    provider_ref   TEXT,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT payment_idempotency_unique UNIQUE (idempotency_key)
);

La clé d'idempotence est dérivée de l'intention métier (user:42:order:1337) et non d'un UUID généré à chaque clic. Un second appel identique retombe sur la contrainte unique et renvoie la transaction existante au lieu d'en créer une nouvelle.

Les webhooks arrivent mal

Trois propriétés qu'il faut tenir pour acquises :

  • Ils sont rejoués. Le même événement peut arriver cinq fois.
  • Ils arrivent dans le désordre. Le succeeded peut précéder le pending.
  • Ils sont publics. N'importe qui peut poster sur ton endpoint.

La signature se vérifie sur le corps brut, avant tout parsing JSON : un JSON.parse suivi d'un JSON.stringify réordonne les clés et invalide le calcul :

export async function POST(request: Request) {
  const raw = Buffer.from(await request.arrayBuffer());
 
  const event = provider.verifyWebhook(raw, request.headers);
  if (!event) return new Response("invalid signature", { status: 401 });
 
  await applyTransition(event.reference, event.status);
  return new Response("ok");
}

Et la transition d'état est monotone : une fois succeeded, aucun webhook ne peut faire redescendre la transaction.

UPDATE payment
   SET status = $2, provider_ref = $3
 WHERE reference = $1
   AND status = 'pending';

Le AND status = 'pending' est ce qui rend le rejeu inoffensif. Sans lui, un webhook failed arrivé en retard annule un paiement déjà encaissé.

Le filet de sécurité : la réconciliation

Les webhooks finiront par manquer un événement. Une tâche périodique rattrape les transactions restées en suspens :

const stale = await db.payment.findMany({
  where: { status: "pending", createdAt: { lt: minutesAgo(3) } },
  take: 200,
});
 
for (const payment of stale) {
  const status = await providers[payment.providerId].fetchStatus(payment.reference);
  if (status !== "pending") await applyTransition(payment.reference, status);
}

Cette boucle, triviale à écrire, est ce qui transforme une intégration « qui marche en démo » en système exploitable. Elle utilise exactement la même fonction applyTransition que le webhook, donc les mêmes garanties.

Ce que j'ai appris à mes dépens

  • Ne jamais faire confiance au montant renvoyé par le webhook sans le comparer à celui stocké. Un écart signale soit un bug, soit une tentative de fraude.
  • Journaliser la charge utile brute de chaque webhook, signature comprise. Le jour où un agrégateur conteste une transaction, c'est la seule preuve.
  • Tester les échecs, pas les succès. Les sandbox proposent des numéros qui échouent, qui expirent, qui ne répondent jamais. Ce sont les seuls cas qui apprennent quelque chose.
  • Mesurer le taux de succès par agrégateur et par opérateur. C'est ce qui permet, plus tard, de router intelligemment vers le canal le plus fiable, exactement la même logique que pour la distribution d'OTP multi-canal.

Articles liés

4 min de lecture

Ma méthode pour un front-end Angular qui reste maintenable

Découpage par domaine, composants standalone, signaux contre RxJS, intercepteurs et détection de changement : les décisions que je prends au démarrage d'un projet Angular.

  • Angular
  • TypeScript
  • Architecture
  • Frontend
Lire l'article
5 min de lecture

Ma méthode pour une application Flutter prête pour la production

Choix de la gestion d'état, architecture en couches, temps réel et publication sur les stores : ce que j'applique sur mes applications Flutter depuis Woila et Djangi.

  • Flutter
  • Dart
  • Mobile
  • Architecture
Lire l'article