A Payment Is Not “Successful” Just Because the Button Worked


 When I first started thinking about payment features, the visible part looked simple: a user clicks a button, completes a payment and sees a success message.

But the button is only the beginning of the transaction.

The browser can lose its connection. A user can close the page after paying. The payment provider can confirm the transaction seconds later. A callback can arrive twice. A user can even refresh the success page and accidentally trigger the same action again if the system is designed badly.

That is why I no longer treat a payment as one frontend event. I treat it as a process with several states.

The three states every payment flow needs

At minimum, I design around these states:

  • Pending: the payment has started, but the application has not independently confirmed the final result.

  • Successful: the server has verified the transaction and completed the correct action once.

  • Failed: the transaction did not complete, was rejected or could not be verified.

There may also be states such as abandoned, reversed or refunded, depending on the payment provider and the product. The important thing is that the interface and database agree on what each state means.

A spinning loader is not a payment status. It is only a visual sign that the page is waiting.

Why the browser should not be the final authority

After checkout, a provider may redirect the user to a success page. That redirect is useful for the user experience, but I should not trust it as the only proof that money was received.

Anything in the browser can be interrupted or manipulated. The application server should verify the transaction using the provider's official API or a signed webhook before delivering value.

For example, if a user pays to activate a profile or purchase a service, my system should confirm all the important details:

  • the transaction reference belongs to the correct order;

  • the amount matches what the order requires;

  • the currency is correct;

  • the provider reports a successful status;

  • the transaction has not already been processed.

Only after those checks should the order be marked as paid.

Every transaction needs its own reference

“The payment I made yesterday” is difficult to investigate. A unique reference gives the user, the application and support the same identifier.

I create the order record before sending the user to checkout. That record stores the expected amount, purpose, user, status and unique reference. When confirmation arrives, the server looks up that exact order instead of guessing what the payment was meant to do.

This also makes support easier. A receipt can show the reference, date, amount and current status without exposing sensitive payment information.

Duplicate callbacks must not create duplicate value

A webhook can be retried. A user can refresh a page. A network request can time out even though the server completed it.

For this reason, the operation that delivers value must be idempotent. In plain language, repeating the same confirmed request should not repeat the reward.

If a transaction reference has already activated an order, the next callback for that reference should return safely without activating it again. The database should enforce this protection where possible, rather than depending only on a frontend button becoming disabled.

This matters even when the product is not a wallet. Duplicate activation, duplicate credits or duplicate order fulfilment can still cost money and damage trust.

Pending needs a useful explanation

Pending is not automatically an error.

Sometimes the provider is still processing the transaction. Sometimes the webhook has not arrived. Sometimes the application needs to check the transaction again from the server.

Instead of showing a frightening error, I prefer a message such as:

We have received your payment attempt and are confirming it. Do not pay again yet. Use this reference if you contact support.

The page should also provide a safe way to check the status again. It should not tell the user to repeat the payment immediately, because that can lead to a double charge.

Logs should help without exposing secrets

When a payment fails, I need enough information to understand where it failed. Useful logs can include the internal order ID, transaction reference, provider response status and time of the event.

Logs should not contain secret keys, complete card details or other sensitive information. Debugging becomes dangerous when the system collects more private data than the problem requires.

The small checklist I now use

Before I consider a payment flow ready, I ask:

  1. Is an order created before checkout begins?

  2. Does every order have a unique reference?

  3. Does the server independently verify the final status?

  4. Are amount, currency and order ownership checked?

  5. Can the same confirmation be processed safely more than once?

  6. Can the user understand pending, successful and failed states?

  7. Is there enough transaction history for support to investigate a complaint?

  8. Have I tested refreshes, slow networks and interrupted redirects?

The payment button is the easiest part to see, but the reliability behind it is what users remember.

When money is involved, a product should never force the user to guess what happened.

Suggested internal links:Building Digital Products for Nigerian Users” and “How I Handle a Serious Bug When I’m Responsible for Everything.

Post a Comment

Previous Post Next Post

Contact Form