Contact
Fintech · 10 min read · Seanet Infotec

Routing payments across three gateways
without double-charging anyone

Every Nigerian checkout works beautifully until one gateway has a bad afternoon. Here is how we build the routing layer when a client cannot afford to be down during peak.

A single-provider checkout has one failure point, and in Nigeria that point gets tested in public. Card rails congest. A bank's transfer channel goes quiet for ninety minutes. A gateway has a bad deploy at 7pm on a Friday, which is when most of the month's revenue moves.

Clients usually ask for "all three" as an insurance policy. Done carelessly, three providers make things worse. You get three webhook formats, three settlement files, and a new way to charge the same customer twice.

The three failures worth designing for

Gateway downtime is the obvious one, and the least interesting. A provider that is fully down returns errors, your fallback fires, life continues.

The second is a channel failure inside a healthy gateway. Card processing is fine while bank transfer confirmations stall. If your only payment option on the page is transfer, the customer sees a spinning page even though the provider's status page is green.

The third is latency, and it causes the most damage. The charge request times out on your server. Did it reach the gateway? Did the customer get debited? Nobody knows yet, and this is exactly the moment an implementation decides to retry and creates a duplicate.

Model the intent before you involve a provider

Before any call to Flutterwave, Paystack or Monnify, write the payment intent to your own database. One row per thing the customer is trying to pay for, with your own reference, the amount, the currency, the customer, the channel you are about to try, and a status of pending.

Every provider call references that row. Every webhook resolves back to it. If your application cannot answer "what happened to this payment" without asking a gateway, the schema is wrong.

The reference should be generated by you and sent to the gateway, not copied from their response. When you generate it, you own the join key between your books, their dashboard, and the settlement file.

Amounts as integers, in kobo

Store and transmit amounts as integers in the minor unit. Ten thousand naira is 1000000 kobo, and it stays that way through the database, the API call and the reconciliation file. Floating point naira produces rounding drift that surfaces months later as a reconciliation item nobody can explain.

Round once, at the point a human sees a number, and never in the middle of a calculation. Gateways differ in how they document this; check each one's expected unit against a live test charge rather than a blog post.

The redirect is not proof of payment

The customer comes back to your success page with a query string. That query string is a claim, not evidence. Anyone can type it.

Verify server-side against the provider's verify endpoint using your reference, then check four things yourself: the amount, the currency, the status, and that the payment belongs to the intent you think it belongs to. Only then mark it paid and fulfil.

We have seen a portal mark orders paid from the callback alone. It took one curious customer editing a URL to discover that.

Webhooks are the source of truth, and they will arrive twice

Assume every webhook arrives at least twice, out of order, and long after you stopped watching. Providers retry when your endpoint is slow or returns a non-200, and retries are not suppressed if the first one actually succeeded on your side.

So the handler is a pure function of the event id. Insert the event id into a unique index. If the insert fails, you have already processed it and you return 200 immediately. That single constraint removes most duplicate-fulfilment bugs before they exist.

Verify the signature on the raw request body before parsing. Paystack sends an HMAC in `x-paystack-signature`, Monnify sends `monnify-signature`, and Flutterwave compares a shared `verif-hash` header against your configured secret. Two of those are HMAC over the body, one is a plain comparison, and mixing them up either rejects real traffic or accepts forged traffic. Confirm the exact scheme in each provider's current documentation before you ship.

Then answer fast. Do the minimum write, queue the rest, return 200. A handler that calls three internal services synchronously will time out under load and the provider will retry, which is how you get the duplicates you were trying to prevent.

What is safe to retry

This is the rule we will not bend. Retry only on a definitive failure: a decline code, an invalid request, a validation error. Never automatically retry after a timeout, a connection reset, or any 5xx from the provider. Those are unknown outcomes, and an unknown outcome on a card charge means the customer may already have been debited.

Unknown outcomes go to a reconciliation queue. A human or a scheduled job queries the provider by reference, and the status resolves from their record, not from a guess.

For fallback across providers, the same discipline applies. If gateway A returns a hard decline, trying gateway B on a different card route is reasonable. If gateway A times out, switching to B and charging again is how a customer ends up paying twice and calling your support line.

Reconciliation is the part nobody budgets for

Every provider sends a settlement report, and those reports are where the truth lives. Build the job on day one, not in month six.

Pull yesterday's settlements, match each line to your intent by reference, and classify. Matched and settled. Paid but not settled yet, which is normal for T+1 cycles. Amount mismatch. Paid twice. Settled with no intent, which means someone else's money is sitting in your account and you need to find out whose.

Anything unmatched goes to an exception queue with the raw provider payload attached. Keep that raw JSON forever. When a customer disputes a charge eight weeks later, the provider dashboard will show a summary and your stored payload will show what actually happened.

Behaviour in Nigeria that breaks generic assumptions

Bank transfers arrive with the payer's name, not your reference, and the narration field is often truncated or creatively spelled. Match on amount plus a short reference prefix, and accept that some customers will underpay by a few naira or pay from a business account with a different name.

Transfers can take minutes to confirm. A UI that expires the order after sixty seconds will lose sales from customers who already sent the money. Show a pending state with a reference number and a way to check status later.

Partial payment happens, especially with cooperatives and school fees. Decide up front whether you support credit balances or refunds, because retrofitting that decision into a live ledger is painful.

Sandbox and production do not behave identically, particularly around transfer simulation and webhook timing. Budget for small live test transactions on each channel before launch, and keep the amounts small enough that a duplicate costs you nothing.

What to instrument

Log every state transition on the intent with a timestamp, the provider, and the raw response code. Alert on three things: intents pending beyond fifteen minutes, webhook signature failures, and settlement mismatch count above zero. Those three cover most incidents before a customer reports them.

Where to start

If you already have a working single-provider checkout, do not rebuild it. Add the intent table and the idempotent webhook handler first. Those two changes eliminate the duplicate-charge class of bug on their own. Fallback routing across providers is the last piece, not the first, and it is worth doing only after reconciliation is running.

We build and operate payment portals on this pattern, including the settlement and exception handling. If you want a second opinion on an existing integration, we can review the webhook path and the reconciliation job and tell you plainly where it would fail under load.

FlutterwavePaystackMonnifyPaymentsNGN