Why my payment system never retries a payment on its own
When M-Pesa goes quiet after a payment request, the tempting fix is to send it again. Here's why Mintfa asks instead, and hands the hard cases to a person.
A customer taps Pay on a merchant’s site. Mintfa sends an M-Pesa STK push, the customer’s phone shows a prompt, they enter their PIN, and a few seconds later Safaricom calls back to say the payment went through.
That’s the happy path. This post is about the other one: the callback never arrives.
The tempting fix
When a payment request goes quiet, the first instinct is to send it again. Networks are flaky, the request probably got lost, so retry it. That’s the right call for most API requests.
It’s the wrong call for money. A missing callback doesn’t tell you the payment failed. It tells you that you don’t know. The prompt may have reached the phone, the customer may have paid, and the callback may simply be late or lost. If you resend the request, the customer can end up paying twice.
So Mintfa follows one rule: a delayed payment is better than a double charge.
What happens instead
Mintfa never resubmits a payment on its own. A recovery worker picks up payments that have been pending for too long, and works through them in a fixed order:
- Ask before acting. It queries M-Pesa for the status of the original request. If M-Pesa says it succeeded or failed, Mintfa records that outcome as if the callback had arrived.
- Refuse to guess. Querying needs the reference M-Pesa returned when the request was accepted. If that reference was never saved (say the request timed out before M-Pesa answered), the worker can’t safely ask, and it doesn’t resubmit either.
- Hand it to a person. Anything the worker can’t resolve goes to an operator with the full history, instead of being retried in a loop.
In simplified form, the decision looks like this:
// Simplified: the real worker also handles backoff, attempt limits and locking.
async function recover(payment: PendingPayment) {
if (!payment.providerReference) {
return escalate(payment, 'no provider reference: cannot query safely');
}
const status = await mpesa.queryStatus(payment.providerReference);
if (status === 'succeeded' || status === 'failed') {
return settle(payment, status); // same path as a real callback
}
if (payment.recoveryAttempts >= MAX_ATTEMPTS) {
return escalate(payment, 'still unknown after retries');
}
return scheduleNextCheck(payment);
}
The worker retries the question, never the payment.
The other side: retries from merchants
The same thinking applies one layer up. A merchant’s server calls Mintfa to create a payment, the connection times out, and their code retries. Did the first request create a payment or not?
Every write request to Mintfa carries an idempotency key. If the same key arrives again, Mintfa returns the original response instead of creating a second payment. While the first request is still in flight, the key is held under a short lease, so two retries arriving at once can’t both slip through.
Between the two, a retry anywhere in the chain asks a question or replays an answer. Neither one can move money twice.
Keeping the books honest
Recovery decides what happened. The ledger makes sure the record of it can’t drift. Every movement is a balanced double-entry journal in integer cents, and the database itself enforces that a wallet’s available balance always equals posted minus pending, held and reserved. A bug in application code can fail loudly, but it can’t quietly write an impossible balance.
What I’d tell someone building this
- Treat “no response” as unknown, not failed. Design a state for it.
- Retry reads freely. Retry writes only when the provider guarantees they’re idempotent.
- Save the provider’s reference the moment you get it. Without it, you can’t ask about the payment later.
- Give unresolved cases a human owner. A queue an operator can see beats a loop nobody watches.
Mintfa is still in development and tested against the M-Pesa sandbox, so these rules haven’t met real-world traffic yet. They’re the part of the design I’m most sure about.