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 les quitte jamais.

StatutSignificationVos fonds
pendingEn sandbox, l'instant avant la prise en charge par l'opérateur simulé. En live, seulement un ancien payout du Dashboard qui attend encore votre confirmation, que vous confirmez ou rejetez dans le DashboardRéservés en sandbox. Un ancien payout live ne réserve rien avant votre confirmation
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
cancelledArrêté avant tout envoi, à partir de la version d'API 2026-10-12Libérés, aucuns frais prélevés

POST /transfers crée un payout avec processing, ou avec review lorsqu'une mesure de protection le retient, et rien d'autre. Un payout arrêté avant tout envoi se termine en cancelled : rejeté dans le Dashboard, son failure_reason est merchant_rejected ; annulé par Wajub, cancelled_by_wajub. Un payout refusé par la revue de Wajub (admin_rejected) ou par l'opérateur se termine en failed.

cancelled est arrivé avec la version d'API 2026-10-12. Un appel fixé sur une version antérieure lit un tel payout en failed, avec le même failure_reason, et le trouve avec status=failed.

Événement émis ou non

Il existe cinq é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
cancelledtransfer.cancelled, une fois 2026-10-12 devenue la version d'API courante. transfer.failed jusque-là
pending, en sandboxAucun événement

Un webhook est structuré pour la version d'API courante de Wajub, pas pour celle que vous fixez. Tant que 2026-10-12 n'est pas la version courante, un payout annulé est annoncé en transfer.failed, avec status: failed.

transfer.created contient toujours processing ou review, jamais pending.

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-10-11",
"pending_webhooks": 1,
"request": {
"id": null,
"idempotency_key": "idem_zR4mK8vQ2pL7tB3nY6hC9dF1jS5aG0eU"
},
"data": {
"id": "po_9Kdm2LpXv4tRb7nQ3sZc",
"reference": "supplier-892",
"provider_reference": "MP260912.1431.B84213",
"amount": 10000,
"currency": "XAF",
"description": "Supplier payment #892",
"reason": null,
"metadata": {
"invoice": "INV-2026-0892",
"reservation": {
"amount": 10000,
"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 envoyez votre propre reference lors de la création du payout : elle revient dans data.reference avec 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();

// The client verifies with the secret it was built with: webhookSecret,
// or WAJUB_WEBHOOK_SECRET when you pass none.
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'],
    );
  } 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 410 alors que vous souhaitez une nouvelle tentative410 Gone est la seule réponse qui arrête définitivement la livraison. Tout autre échec, 4xx compris, est retenté pendant 72 heures au plus
Traiter avant de répondreLe délai d'expiration est de 10 secondes. Un handler lent transforme un événement en livraisons répétées et vos nouvelles tentatives s'accumulent derrière votre propre latence

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

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 cinq événements de transfert. Ce comportement est normal et ne constitue pas un doublon. Abonnez-vous aux cinq é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 continue de l'interroger. 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. Lire un transfert compte dans la limite générale de votre équipe, soit 120 requêtes par minute avec le plan par défaut, et non dans la part réservée à l'envoi de payouts. Une boucle consomme pourtant 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 ?