Emmanuel EbriEmmanuel Ebri
Back to Blog
PaymentsFintechWebhooks

Payment Integration Outside Stripe's World

What changes when the provider, the currency and the failures differ

6 min read

Payment Integration Outside Stripe's World cover

Almost every tutorial about accepting payments assumes a world with a mature SDK, a stable webhook contract, a currency with two decimal places that nobody argues about, and a card that either works or declines within two seconds.

Plenty of us build outside that world. Different providers, different currencies, different failure modes, and users paying by bank transfer from an app that may confirm in four seconds or in four minutes. The principles that survive the move are the interesting part, because they turn out to be the ones that were load bearing all along.

Money is an integer

The first decision, and the one that is most expensive to change later, is how you represent an amount.

Store the smallest unit as an integer. In naira that means kobo, so twelve thousand five hundred naira is stored as 1250000. Never a float, never a decimal string that gets parsed in three different places, never a number that has been through a currency formatter and back.

The reason is not theoretical. Floating point addition of currency values produces results that are almost right, and almost right compounds. Split an amount three ways for an installment plan, apply a percentage discount, and reconcile against a provider's figure, and you find yourself one kobo out with no idea which of the six operations introduced it.

Integers make the whole class of problem disappear. Formatting happens once, at the edge, when a number becomes a string for a human to read. Everywhere else it is an integer and arithmetic behaves.

The corollary is that your API accepts a product identifier, not an amount. If the client sends a price, then the client has an opinion about the total, and two systems with opinions about money will eventually disagree. Resolve the price on the server from your own catalogue, every time.

The provider is the source of truth, the browser is a hint

The mental model that causes the most bugs is treating the user's return from the payment page as confirmation.

It is not. It is a hint that something probably happened. The user might close the tab. Their connection might drop on the redirect. They might screenshot a success page from a previous attempt. The only authoritative statement about a payment is the one that comes from the provider, verified.

Verify the raw body, compare in constant time, acknowledge before you work.

Three things in there are worth stating explicitly.

Verify against the raw body. Any middleware that parses and re-serialises JSON before you compute the signature will produce a different byte sequence and a failing comparison, and the resulting bug is genuinely unpleasant to trace.

Compare in constant time. It is one function call and it removes a timing side channel.

Acknowledge before you work. Providers time out waiting for your response and then retry, which means slow processing inside the handler turns one event into several. Respond immediately, then do the work in a job, which also means a crash halfway through processing does not lose the event.

Everything gets called twice

Confirmation arrives through the redirect and through the webhook, and either can land first. Providers retry when acknowledgement is late. Users refresh. Queues redeliver.

So the settlement path has to be idempotent by construction rather than by luck. Keyed on the provider reference, which is the natural unique identifier you have been given for free, enforced with a unique index so the database rejects the second write rather than your application hoping to notice.

This is where the double entry ledger earns its keep. If every movement is a pair of entries recorded against a reference, then a duplicate settlement is a duplicate pair, and a unique index on the reference makes it impossible rather than merely unlikely. Reconciliation becomes a query rather than a spreadsheet.

About skipping the SDK

I talk to my provider over plain HTTPS with hand written request bodies, and I would make that choice again in this context, with a caveat.

What it bought me: no dependency to keep current, no version treadmill, and complete visibility into what is on the wire. When a request fails I am debugging my code and their API, not a wrapper's interpretation of both. Provider documentation in this part of the world moves, and a thin layer I control adapts faster than waiting for a library update.

What it costs: I write the retry logic, the error mapping and the signature verification myself, and I own the correctness of all three. That is fine for four endpoints. It would not be fine for forty, and if the provider's SDK is well maintained and matches your language properly, take it.

The point is that this is a decision with a stated reason, not a default. Both answers are defensible. Only one of them is defensible without thinking about it, and it is neither.

The parts nobody writes about

A few things I learned the hard way that do not appear in integration guides.

Bank transfer confirmations are not instant, and your interface must be honest about pending as a real state rather than treating it as a slow success. Users will refresh, so pending needs its own screen that answers what happens next.

Test keys and live keys should be structurally impossible to confuse. Different environment variable names, a startup check that refuses to boot with a live key against a non production database, and a visible indicator in the interface when running in test mode.

Reconciliation is a feature, not an afterthought. Somewhere there needs to be a job that fetches the provider's record of the day and compares it against yours, and reports differences. You will find some. Finding them yourself is much better than a customer finding them for you.

And keep the provider's raw response. Store it. When a dispute arrives in six months, the record of exactly what they told you and when is the only thing that settles it.

What actually transfers

If you move from one provider to another, or one market to another, the code changes and the principles do not.

Money is an integer in the smallest unit. Price resolves on the server. The provider is authoritative and the browser is a hint. Everything that can be called twice must be idempotent. Every movement is recorded so it can be reconciled.

Those five hold whether you are integrating Stripe, Paystack, Flutterwave or something that does not exist yet. The SDK is the part that changes. The discipline is the part worth learning.

I build fintech products at E-Bringgs Technologies, in naira, over an integration written by hand on purpose.