Aller au contenu

Statuts et webhooks

La signification du statut d'un payout, les événements émis et leur traitement.

201 Created confirme la réception d'une requête, pas le paiement d'une personne. Entre cette réponse et l'arrivée des fonds sur un téléphone se trouvent un opérateur, une file d'attente et, parfois, une personne.

Le statut indique où se trouve un payout. Le webhook vous informe de son évolution. Cette page explique la signification de chaque statut pour vos fonds, les événements émis ou non, et les conditions permettant à un handler de rester correct après une troisième livraison en double.

Les six statuts

Trois décrivent un payout toujours actif qui retient vos fonds. Les trois autres sont définitifs. Un payout ne quitte jamais l'un de ces derniers.

StatutSignificationVos fonds
pendingEnregistré, en attente de confirmation par un membre de votre équipeRéservés
reviewRetenu par Wajub avant exécution pendant la vérification d'une mesure de protectionRéservés
processingTransmis à l'opérateur. Un payout API commence dans cet étatRéservés
succeededLe destinataire a reçu les fonds et complete passe à trueDébités, frais prélevés
failedLes fonds ne sont pas arrivés et failure_reason indique pourquoiLibérés, aucuns frais prélevés
cancelledRefusé avant toute exécutionLibérés

Votre intégration ne peut jamais produire deux de ces six statuts. POST /transfers crée un payout avec processing, ou avec review lorsqu'une mesure de protection le retient. pending et cancelled appartiennent à la file de confirmation de la Konsole. Un payout reste pending tant qu'un collègue doit l'approuver, puis passe à cancelled en cas de refus. Vous pouvez les lire avec GET /transfers si votre équipe utilise cette file, mais jamais les obtenir en réponse à votre propre appel.

Événement émis ou non

Il existe quatre événements de transfert. Ils ne couvrent pas les six statuts. Ces absences sont importantes.

Statut atteint par le payoutÉvénement reçu
processing, à la créationtransfer.created, puis transfer.processing
reviewtransfer.created, puis plus rien
processing, après approbation d'une retenuetransfer.processing
succeededtransfer.succeeded
failedtransfer.failed
pending ou cancelledAucun événement

transfer.created contient toujours processing ou review, jamais pending. Le payload est sérialisé après la validation de la transaction de création, pas lors de l'insertion. La ligne pending de courte durée qui existe dans cette transaction ne vous parvient donc jamais.

Les trois séquences réellement observées

Le payout réussit

Quatre secondes, trois événements. Seul le dernier indique un mouvement de fonds.

Un payout réussi

  1. transfer.createdstatus: processing
    14:31:02

    Le payout est enregistré et votre solde est réservé. Aucun fonds n'a encore quitté le compte.

  2. transfer.processingstatus: processingMTN MoMo
    14:31:02

    Transmis à MTN MoMo. Le statut reste identique à l'événement précédent, car seul le détenteur du payout a changé, pas le payout lui-même.

  3. transfer.succeededstatus: succeeded
    14:31:06

    Le destinataire a reçu les fonds. La réservation est réglée, les frais sont prélevés et trois clés apparaissent sur l’objet.

    complete
    true
    succeeded_at
    2026-09-12T14:31:06+00:00
    provider_reference
    MP260912.1431.B84213

Les deux premiers événements ne vous apprennent rien de plus que la réponse 201. Agissez sur transfer.succeeded et ignorez les autres, sauf si vous affichez une chronologie au support.

Le payout échoue

Les deux mêmes événements sont suivis d'un troisième différent. Un échec survenu dans Wajub avant la transmission à un opérateur et un échec renvoyé par l'opérateur sont identiques sur le réseau : même séquence, même plage de temps et même nom d'événement.

Un payout échoué

  1. transfer.createdstatus: processing
    14:31:02

    Identique au payout réussi. Rien ici ne permet de prévoir le résultat.

  2. transfer.processingstatus: processingMTN MoMo
    14:31:02

    Toujours identique. L'opérateur détient le payout et n'a pas encore répondu.

  3. transfer.failedstatus: failed
    14:31:09

    L'opérateur l'a refusé. Votre réservation est libérée, les frais ne sont jamais prélevés et deux clés propres à ce statut apparaissent.

Ces deux clés contiennent toute l'information fournie sur un échec. Lisez-les après avoir vérifié le statut au lieu de supposer leur présence.

Les clés d'échec dans data
{
"status": "failed",
"complete": false,
"failure_reason": "provider_rejected",
"failure_message": "Recipient account is barred for incoming transfers."
}

failure_reason est un code court à utiliser dans vos branches et vos logs. failure_message est une phrase à montrer à une personne. Transferts liste les codes écrits par Wajub et explique pourquoi les autres viennent directement de l'opérateur. Pour un payout sur le circuit Wajub, transfer.failed libère aussi votre réservation. Le principal et l'estimation des frais reviennent dans available avant la fin de votre handler.

Le payout est retenu

transfer.created arrive avec status: "review", puis plus rien pendant des minutes ou des heures. Le payout attend l'intervention d'une personne chez Wajub. Aucun événement ne marque cette attente, car le payout a été créé directement dans cet état.

La résolution produit l'un de deux événements. transfer.processing signifie que le payout est approuvé et suit maintenant son traitement normal. transfer.failed avec failure_reason: admin_rejected signifie qu'il est refusé.

Structure d'un événement

Tous les événements de tous les produits utilisent la même enveloppe. Le transfert constitue l'intégralité de data, avec la même structure que la réponse de GET /transfers/{uid}.

transfer.succeeded
{
"id": "evt_9Lm4tQ7wRc2vZ8kN5pXbT3jH",
"event": "transfer.succeeded",
"livemode": true,
"created": "2026-09-12T14:31:06+00:00",
"api_version": "2026-09-01",
"pending_webhooks": 1,
"request": {
"id": null,
"idempotency_key": "idem_zR4mK8vQ2pL7tB3nY6hC9dF1jS5aG0eU"
},
"data": {
"id": "po_9Kdm2LpXv4tRb7nQ3sZc",
"reference": null,
"provider_reference": "MP260912.1431.B84213",
"amount": 10000,
"currency": "XAF",
"description": "Supplier payment #892",
"reason": null,
"metadata": {
"invoice": "INV-2026-0892",
"reservation": {
"amount": 10100,
"currency": "XAF",
"fee_estimate": 100,
"reserved_at": "2026-09-12T14:31:02+00:00"
}
},
"sandbox": false,
"status": "succeeded",
"complete": true,
"source": "api",
"succeeded_at": "2026-09-12T14:31:06+00:00",
"created_at": "2026-09-12T14:31:02+00:00",
"updated_at": "2026-09-12T14:31:06+00:00",
"provider": {
"slug": "mtn_momo",
"name": "MTN MoMo"
},
"beneficiary": {
"id": "ben_7Kq2mX9vL4tRb3nP8sZc",
"name": "Aminata Diallo",
"phone": "+237670000000",
"email": null
},
"payment_method": {
"id": "pm_3Nq8wR2kL5vT7bY4hC6d",
"account_number": null,
"phone": "+237670000000"
}
}
}

L'enveloppe contient sept clés. Deux d'entre elles ne correspondent pas à ce que leur nom suggère.

idstringfacultatif
L'id de l'événement, evt_ en live et evt_test_ dans la sandbox. Votre clé de déduplication, voir ci-dessous.
eventstringfacultatif
Le nom de l'événement. Ce champ s'appelle event, pas type.
dataobjectfacultatif
L'objet transfert lui-même, sans enveloppe supplémentaire.
livemodebooleanfacultatif
false pour un payout sandbox. Le seul champ qui distingue les deux environnements.
api_versionstringfacultatif
La version qui a déterminé la structure de data. Fixer une version plus ancienne change les clés présentes.
pending_webhooksnumberfacultatif
Le nombre de vos endpoints dans lesquels cet événement a été placé. Il ne s'agit pas d'un compteur de nouvelles tentatives.
requestobjectfacultatif
Présent uniquement pour respecter la structure. Les deux clés sont inutiles, voir ci-dessous.

L'une de ces sept clés mérite un avertissement. Une erreur à cet endroit ne produit aucun message.

request vous sera peu utile. request.id n'est jamais renseigné. request.idempotency_key est un identifiant généré pour l'événement lui-même, sans rapport avec la valeur Idempotency-Key envoyée avec POST /transfers. Pour relier un événement à votre propre appel, utilisez data.id ou placez votre référence dans metadata lors de la création du payout. Les métadonnées reviennent dans chaque événement.

Deux caractéristiques de data sont à connaître avant de l'utiliser. Le bénéficiaire, le moyen de paiement et le prestataire sont toujours développés. Un événement de transfert indique donc la destination des fonds sans second appel API. De plus, metadata ne contient pas uniquement vos données. Sur un payout live qui utilise le circuit Wajub, Wajub ajoute un objet reservation, puis un objet settlement lorsque le payout implique une conversion de devise. Lisez les clés que vous avez ajoutées et ne vérifiez jamais la structure exacte de l'ensemble.

Le handler

Effectuez trois opérations dans cet ordre : prouvez que l'événement vient de Wajub, répondez, puis traitez-le. Répondre avant le traitement n'est pas une optimisation. Cette méthode empêche une base de données lente de provoquer une multiplication des nouvelles tentatives.

import express from 'express';

const app = express();

app.post('/webhooks/wajub', express.raw({ type: 'application/json' }), (req, res) => {
  let event;

  try {
    event = wajub.webhooks.constructEvent(
      req.body,
      req.headers['x-wajub-signature'],
      req.headers['x-wajub-timestamp'],
      process.env.WAJUB_WEBHOOK_SECRET,
    );
  } catch {
    return res.status(400).send('invalid signature');
  }

  res.status(200).end();

  queue.push(event);
});

Le renvoi d'une réponse 400 pour une mauvaise signature est volontaire. Rejouer les mêmes octets échouerait de la même façon, donc une nouvelle tentative n'apporterait rien. Vérification de signature détaille l'algorithme, l'obligation d'utiliser le corps brut et la fenêtre de cinq minutes de l'horodatage.

Le traitement doit être effectué en dehors de la requête. Il se divise d'abord selon le nom de l'événement, puis selon son statut.

async function handleTransferEvent(event) {
  const transfer = event.data;

  if (await alreadyProcessed(event.id)) {
    return;
  }

  switch (event.event) {
    case 'transfer.succeeded':
      await markSupplierPaid(transfer.id, transfer.amount, transfer.currency);
      break;

    case 'transfer.failed':
      await flagPayoutFailure(transfer.id, transfer.failure_reason);
      break;

    case 'transfer.created':
      if (transfer.status === 'review') {
        await notifyOpsPayoutHeld(transfer.id);
      }
      break;
  }

  await recordProcessed(event.id);
}

transfer.processing est volontairement absent. Toute action à ce stade serait prématurée. Les fonds n'ont pas bougé. Un handler qui livre une commande avec processing la livre avant même la tentative de payout.

Les quatre causes d'erreur

Chacune de ces erreurs produit un handler qui passe la revue, fonctionne en préproduction et paie deux fois une personne en production.

ErreurConséquence
Faire confiance à l'ordre d'arrivée des événementsLes livraisons sont placées en file et relancées séparément. transfer.succeeded peut arriver avant transfer.processing. Lisez l'état dans data.status, jamais dans la séquence
Ne pas dédupliquerLe même événement peut être livré plusieurs fois. Stockez event.id et vérifiez-le en premier
Renvoyer 4xx alors que vous souhaitez une nouvelle tentativeTout code 4xx, sauf 408 et 429, arrête définitivement la livraison. Un framework qui répond 400 à un corps mal formé abandonne l'événement
Traiter avant de répondreLe délai d'expiration est de 10 secondes. Un handler lent transforme un événement en cinq livraisons et vos nouvelles tentatives s'accumulent derrière votre propre latence

Un abonnement à tous les événements en produit plus de quatre

Un endpoint abonné à * reçoit balance.updated lors du règlement de la réservation et fee.charged lorsque Wajub prélève ses frais, en plus des quatre événements de transfert. Ce comportement est normal et ne constitue pas un doublon. Abonnez-vous aux quatre événements nécessaires, sauf si les autres vous sont utiles.

Délai avant de vous inquiéter

Dans la sandbox, le délai fixe est d'environ deux secondes. En live, il dépend de l'opérateur. Il est généralement de quelques secondes, mais atteint parfois plusieurs minutes.

Si le callback de l'opérateur n'arrive jamais, Wajub l'interroge. Le premier contrôle est effectué 30 secondes après la transmission du payout. Les suivants ont lieu environ toutes les minutes, avec un intervalle croissant, soit une dizaine de contrôles sur environ quarante-cinq minutes. Un payout résolu de cette façon produit le même événement transfer.succeeded ou transfer.failed que les autres, mais plus tard. Votre handler ne peut pas distinguer ce cas et ne doit pas essayer.

Lire plutôt qu'écouter

Les webhooks conviennent au payout que vous attendez. La lecture convient au payout que vous contrôlez.

GEThttps://api.wajub.com/transfers/{uid}

Utilisez cet appel pour un rapprochement planifié, pour répondre au support ou pendant le développement, avant de disposer d'une URL publique accessible par Wajub. Ne créez pas une boucle autour d'un payout en attendant succeeded. Cet appel dépend de la limite générale de l'API, soit 120 requêtes par minute avec le plan par défaut. Une boucle consomme la capacité nécessaire à tous les autres appels de votre intégration.

Pour un rapprochement en fin de journée, listez les payouts par période et comparez-les aux événements reçus. Si un payout enregistré chez vous comme processing est définitif chez Wajub, vous avez manqué une livraison. La Konsole affiche chaque tentative effectuée pour vous joindre.

Si vous êtes une plateforme

Un payout effectué pour un compte connecté est livré deux fois : une fois aux endpoints de ce compte et une fois aux vôtres. Votre copie contient un objet account supplémentaire qui désigne le marchand concerné. Un seul endpoint peut ainsi servir tous vos comptes connectés. Sync explique leur configuration.

Que pensez-vous de ce contenu ?