Un client valide sa commande. Vous appelez requesttopay. La requête met trente secondes, votre client HTTP abandonne, le job échoue. Laravel le relance. Deuxième appel, deuxième débit.
C'est la panne classique du mobile money, et le protocole la résout déjà. Encore faut-il lire la réponse correctement, et pendant plusieurs semaines mon driver ne la lisait pas.
#Ce que MTN renvoie vraiment
MTN MoMo attend un en-tête X-Reference-Id sur POST /collection/v1_0/requesttopay. C'est votre clé d'idempotence, et elle doit être un UUID. Si vous y mettez votre propre numéro de commande, l'API refuse sans expliquer pourquoi.
Rejouez la même référence et l'API ne rejoue pas le paiement. Elle répond ceci :
1HTTP/1.1 409 Conflict23{"code": "RESOURCE_ALREADY_EXIST"}1HTTP/1.1 409 Conflict23{"code": "RESOURCE_ALREADY_EXIST"}
Un 409, avec un code qui dit que la ressource existe déjà. Beaucoup de code PHP traite tout ce qui n'est pas 2xx comme un échec. C'est exactement ce que faisait le mien.
#Pourquoi c'est le pire endroit où lever une exception
Suivez le chemin complet.
L'appelant subit un timeout. Il ne sait pas si MTN a reçu la demande. Il fait la seule chose raisonnable : il rejoue, avec la même clé.
MTN répond 409. Le driver lève une exception. L'appelant voit un échec, en conclut que la demande n'est jamais passée, et repart avec une nouvelle référence.
Cette nouvelle référence est une nouvelle demande de paiement. Le client reçoit un second prompt sur son téléphone. S'il valide, il paie deux fois.
La clé d'idempotence a parfaitement fonctionné. C'est la lecture de sa réponse qui a produit le double débit, et c'est du code à moi, pas du code de MTN.
#Le correctif
Un 409 veut dire que la première requête a été acceptée et que celle-ci n'a rien changé. L'état réel est celui qu'a atteint la demande d'origine, et cette réponse ne vous le dit pas. Donc on renvoie Pending et l'appelant interroge le statut.
1// Une X-Reference-Id rejouée revient en 409 RESOURCE_ALREADY_EXIST.2// C'est la clé d'idempotence qui fonctionne, pas un rejet : la première3// requête a été acceptée et celle-ci n'a rien changé.4if ($response->status() === 409) {5 return new Transaction(6 status: PaymentStatus::Pending,7 amount: $request->amount,8 reference: $reference,9 provider: $this->name(),10 payer: $request->payer,11 raw: ['http_status' => 409, 'duplicate' => true],12 );13}1415if ($response->status() !== 202) {16 throw ProviderException::rejected($this->name(), $response->status(), $this->errorBody($response));17}1// Une X-Reference-Id rejouée revient en 409 RESOURCE_ALREADY_EXIST.2// C'est la clé d'idempotence qui fonctionne, pas un rejet : la première3// requête a été acceptée et celle-ci n'a rien changé.4if ($response->status() === 409) {5 return new Transaction(6 status: PaymentStatus::Pending,7 amount: $request->amount,8 reference: $reference,9 provider: $this->name(),10 payer: $request->payer,11 raw: ['http_status' => 409, 'duplicate' => true],12 );13}1415if ($response->status() !== 202) {16 throw ProviderException::rejected($this->name(), $response->status(), $this->errorBody($response));17}
Le 202 reste le seul succès franc. Le 409 n'est pas traité comme un succès, il est traité comme une absence d'information.
#L'état que presque personne ne modélise
Il existe un troisième cas, et c'est celui qui manque dans la plupart des intégrations que j'ai lues.
Quand la connexion tombe avant la réponse, vous ne savez pas si MTN a reçu la requête. Répondre Failed invite à rejouer, et rejouer peut débiter. Répondre Succeeded est un mensonge.
1try {2 $response = $this->client()->withHeaders($headers)3 ->post('/collection/v1_0/requesttopay', $payload);4} catch (ConnectionException $e) {5 // On ne sait pas si MTN a reçu la requête. La déclarer échouée6 // inviterait à rejouer, et rejouer peut débiter une seconde fois.7 return $this->unknown($request, $reference, $e->getMessage());8}1try {2 $response = $this->client()->withHeaders($headers)3 ->post('/collection/v1_0/requesttopay', $payload);4} catch (ConnectionException $e) {5 // On ne sait pas si MTN a reçu la requête. La déclarer échouée6 // inviterait à rejouer, et rejouer peut débiter une seconde fois.7 return $this->unknown($request, $reference, $e->getMessage());8}
Le statut renvoyé est Unknown, pas Failed. La différence est opérationnelle : Failed autorise une nouvelle tentative, Unknown impose une vérification avant toute chose. Une commande mobile-money:reconcile interroge ensuite le fournisseur pour tout ce qui n'a pas atteint un état final, parce qu'en pratique une partie des callbacks n'arrive jamais.
#Trois états, pas deux
Si vous ne retenez qu'une chose : un paiement mobile money n'a pas deux issues mais trois. Réussi, échoué, et inconnu. Le troisième est le seul qui protège l'argent du client, et c'est celui que le typage de la plupart des intégrations ne permet même pas d'exprimer.
Tant que votre code ne sait pas dire « je ne sais pas », il finira par dire « échec » à un paiement qui est passé.
#Une remarque pour les lecteurs de la zone UEMOA
La BCEAO a reporté au 30 septembre 2026 la date limite de connexion à la plateforme PI-SPI pour les banques, les établissements de monnaie électronique et les établissements de paiement. Fin juin, 80 participants étaient connectés et 74 institutions encore en phase de test réel. Les institutions de microfinance ont jusqu'au 30 juin 2027.
Autrement dit, beaucoup d'équipes de la région écrivent en ce moment même du code de paiement dans l'urgence. Le double débit sur retry est exactement le type de bug qu'on introduit dans ces conditions, et il ne se voit qu'en production, sur l'argent de quelqu'un d'autre.
#Le paquet
Le code ci-dessus vient de catidegla/laravel-mobile-money, qui expose MTN MoMo, Orange Money et Wave derrière une seule interface Laravel. Montants en unités mineures entières, XOF et XAF traitées comme les devises à zéro décimale qu'elles sont, parsing des numéros selon les plans de numérotation nationaux, vérification des webhooks et réconciliation pour les callbacks manquants.
https://github.com/catidegla/laravel-mobile-money
Les corrections décrites ici sont dans le dépôt. Si vous intégrez MTN en ce moment, le point à vérifier dans votre propre code tient en une ligne : regardez ce que fait votre driver quand il reçoit un 409.