Here is a bug that does not look like one.
try {
$response = Http::timeout(30)->post($provider, $payload);
} catch (ConnectionException) {
return $this->markFailed($order); // wrong
}
The connection timed out, nothing came back, so the payment failed. Reasonable. Also wrong, and in a way that takes money from real people.
What a timeout actually tells you
It tells you that you stopped listening. That is all.
Mobile money is asynchronous by construction. You send a collection request, the provider pushes a prompt to the customer's handset, and the customer types a PIN. That might take four seconds or four minutes. They might be on a bus. They might have to switch SIMs. Your thirty second HTTP timeout is a decision your code made about how long to hold a socket open, and it has no relationship at all to whether the payment is happening.
MTN's API is honest about this in a way that surprises people the first time. requesttopay answers 202 Accepted with an empty body. Not a transaction object, not a status, nothing. The only handle you have afterwards is the X-Reference-Id you generated and sent. If you did not keep it, the payment exists and you have no way to ask about it.
So there are three real possibilities behind a timeout, and the response cannot distinguish them:
- The request never arrived. Nothing happened.
- The request arrived, and the provider is prompting the customer right now.
- The request arrived, the customer paid, and the response got lost on the way back.
Marking the order failed and letting the customer press the button again is correct for the first and catastrophic for the other two.
Rule one: the key is generated once and survives the retry
Every provider in this space accepts a client supplied identifier for the request. That identifier is the entire mechanism. Send the same one twice and the provider recognises the second call as a repeat of the first rather than a new payment.
The failure I see most is generating it in the wrong place:
// Wrong. A new key on every attempt means every attempt is a new payment.
function attemptPayment(Order $order) {
return $gateway->collect(
idempotencyKey: Str::uuid(),
amount: $order->total,
);
}
That code has an idempotency key and no idempotency. The key belongs to the logical payment, not to the attempt, so it has to be created once and carried through every retry of that payment.
In the package I ended up writing, the key is not optional and is generated at construction:
$request = CollectionRequest::make(
amount: Money::of(1500, Currency::XOF),
payer: Msisdn::parse('+229 01 97 12 34 56'),
reference: 'ORDER-42',
);
// Persist $request->idempotencyKey against the order before you send anything.
Persisting it before the call rather than after is the part people skip. If the process dies between sending and saving, you are back to a payment you cannot ask about.
Rule two: when in doubt, ask, do not resend
The second half is that a retry after an unclear outcome should not be a retry at all. It should be a question.
// After any timeout or unclear response
$known = $gateway->status($request->idempotencyKey);
if ($known->status === PaymentStatus::Pending) {
// Still waiting on the customer. Do nothing. Poll again later.
return;
}
Query first, send only if the provider has never heard of the key. This turns an ambiguous situation into a definite one using the provider's own records, which are the authority, instead of guessing from your side of a broken connection.
The provider is on your side, and you can still get this wrong
Here is the part I got wrong in my own package, and I only found it because I finally pointed it at a live sandbox.
Resend a reference MTN has already seen and it answers:
HTTP 409 {"message":"Duplicated reference id. Creation of resource failed.","code":"RESOURCE_ALREADY_EXIST"}
Read that message and it sounds like a failure. It is the opposite. It means the provider recognised the reference, refused to create a second transaction, and protected your customer. The idempotency key worked exactly as designed.
My driver had this:
if ($response->status() !== 202) {
throw ProviderException::rejected(...); // including the 409
}
Which is to say: the caller does the right thing, keeps the key, retries after a timeout, and gets an exception for their trouble. And what does a developer do when the retry throws? They assume the payment did not go through and send it again with a fresh reference. Which is the double charge. The exact one the key exists to prevent, arrived at by way of the mechanism meant to prevent it.
So the rule has a third part. A conflict on a replayed key is a successful outcome and must be reported as one. In the fix, a 409 returns the transaction as pending and the caller polls for the truth:
if ($response->status() === 409) {
return new Transaction(
status: PaymentStatus::Pending,
reference: $reference,
raw: ['http_status' => 409, 'duplicate' => true],
);
}
What is worth taking from this is not the fix, it is where the bug was living. I had a hundred passing tests, several of them specifically about idempotency. None of them faked a 409, because it never occurred to me that the provider returned one. You cannot write a test for a response you have not seen. That is the whole argument for spending an hour against a sandbox before you trust your own error handling: the branches you never exercise are exactly the branches your tests do not know exist.
Then the providers flatten the answer anyway
There is one more trap waiting once you are polling properly.
MTN reports a customer who declined the prompt and a customer who never answered it as the same status: FAILED. Those are completely different situations. Someone who pressed cancel has made a decision, and prompting them again is nagging. Someone whose phone was in a bag for ten minutes has not decided anything, and prompting them again is a service.
The distinction is available, but it is in a reason field rather than the status, so it is easy to miss:
// The status alone is not enough. The reason refines it.
'PAYER_REJECTION' => PaymentStatus::Cancelled,
'APPROVAL_REJECTED' => PaymentStatus::Cancelled,
'EXPIRED' => PaymentStatus::Expired,
'PAYER_DELAYED' => PaymentStatus::Expired,
Those four came from the published contract, so the same sandbox run went looking for what the API actually sends. Two of the four never appeared at all. A decline comes back as APPROVAL_REJECTED, not PAYER_REJECTION; a timeout comes back as EXPIRED, not PAYER_DELAYED. Both real ones happen to be mapped, so the distinction survives, but it survived by luck rather than by knowledge. There was also a third string nowhere in my map, INTERNAL_PROCESSING_ERROR, which is a genuine fault and should stay a plain failure.
Check yours against a live response rather than a document. If the strings differ, every decline in your system is being filed as a technical failure and the distinction you think you have does not exist.
And do not let the webhook be the only path
Webhooks in this region are not reliable enough to be the sole route to a final state. Callbacks are dropped, delayed by minutes, or delivered twice.
So poll anything still pending, on a backoff, with a window after which you stop and escalate to a human. Treat the callback as a hint that it is worth asking early, never as the answer itself.
The shape that has worked for me:
- Fulfil only on a confirmed success, never on
PENDINGand never on a callback alone. - Poll pending transactions on an increasing interval.
- Give up after a defined window and mark the transaction for review rather than guessing.
- Log every state change with the idempotency key, because "what did we actually send" is the first question during any dispute.
The one line summary
A timeout is not an outcome, it is the absence of one. Keep the key, ask before you resend, and never let a lost response turn into a second charge.
The package I pulled this out of is catidegla/laravel-mobile-money, one Laravel API across MTN MoMo, Wave and Orange Money. 105 tests, and the README is specific about which paths have met a live sandbox and which have only met the documentation. If you have merchant credentials and ten minutes, I would like to hear what breaks.
Top comments (0)