A customer completes a Flutterwave checkout.
The browser returns to your site with a transaction ID and a status that looks successful.
Your application then calls Flutterwave from the server to verify that transaction.
Verification fails.
The customer sees something like:
We’re checking your payment.
This is one of the most dangerous points in a payment integration because there are two tempting but wrong responses:
- trust the browser and mark the customer paid anyway;
- tell the customer to pay again before you know what happened.
I have had to debug exactly this production state.
In our case, the checkout returned a transaction ID with a completed-looking browser state, but the live verification path failed repeatedly. The useful fix started with separating three facts that had been blurred together:
checkout completion, provider verification, and our own business state are different things.
The redirect is not your payment ledger
Flutterwave Standard redirects the customer back to your application after checkout. That callback is useful because it lets you render a result page and continue the customer journey.
But the customer browser is not a trustworthy payment authority.
A redirect can be:
- replayed;
- bookmarked;
- interrupted;
- altered;
- delivered after your application state changed;
- associated with a stale payment attempt.
Flutterwave’s transaction-verification guidance says the backend should verify the transaction and confirm the expected reference, successful state, currency and amount before granting value.
So I treat the redirect as:
The customer finished a provider flow. Now verify what actually happened.
Not:
Money is definitely settled; unlock everything.
Start with the exact transaction ID
The fastest debugging mistake is verifying a different identifier from the one Flutterwave expects.
A payment system may have several IDs at once:
- your internal payment-attempt ID;
- your
tx_ref; - Flutterwave transaction ID;
- registration/order ID;
- checkout/session URL;
- webhook event ID.
Write them down separately.
When the browser returns, record the Flutterwave transaction ID associated with the callback. Your backend verification request should use the provider identifier required by the documented verification endpoint, while still comparing the result against your own stored tx_ref and business object.
If those identities are mixed, a perfectly valid transaction can look unverifiable.
Then check the environment that created the transaction
This was central to the incident I worked through.
A transaction created under one credential/environment should not be assumed to be visible or verifiable through another credential context.
That means this sequence is dangerous:
- create a pending checkout under credential A;
- store and reuse the attempt;
- rotate production to credential B;
- customer completes the older checkout;
- backend verifies using credential B.
The user can genuinely complete the flow they were given while your current server cannot reconcile that transaction the way you expect.
This is why every attempt should carry provenance:
- provider environment;
- credential fingerprint identifier;
- provider reference;
- creation timestamp.
If you are seeing an unexpected Flutterwave dev checkout even with a live secret, read Flutterwave Live Key Still Opens a Dev Checkout URL?. The stale-attempt bug can show up later as a verification problem too.
Do not let error: {} destroy your investigation
One of the most useful lessons from this incident was embarrassingly small.
Our verification logging showed an error object as:
error: {}
That looked as though Flutterwave returned nothing useful.
But JavaScript Error properties such as message, stack and custom nested response fields are not always enumerable. Passing the raw error into JSON serialization can therefore produce {} while hiding the actual failure.
For provider calls, normalize the error deliberately.
Capture safe fields such as:
error.name;error.message;- HTTP status;
- provider response body/message;
- request URL path (without secrets);
- transaction/reference identifiers;
- retry number;
- timestamp.
If your HTTP client nests provider data under error.response.data, extract the safe provider fields explicitly.
Never log the secret key or raw sensitive payment data.
A payment-verification error that cannot be inspected is an operational bug in its own right.
Verify what your business expected, not only what Flutterwave says
Suppose verification returns a successful provider transaction.
You are still not finished.
Your backend should compare the provider result with the payment your system expected.
At minimum, validate:
Reference
Does the verified transaction correspond to the internal payment attempt/order you are processing?
Final status
Is it in the provider state that your integration treats as successfully paid?
Do not invent your own interpretation of similarly named statuses; use the provider’s documented final state.
Currency
If your registration expected USD, a successful transaction in another currency should not silently satisfy it.
Amount
The verified amount should meet the amount your server calculated for that business transaction.
Do not trust a frontend-submitted amount simply because Flutterwave processed it.
Only after these conditions pass should the internal business effect occur.
Separate payment state from registration/order state
A fragile integration tends to collapse everything into one boolean:
paid = true
A production system benefits from explicit states.
A payment attempt might be:
- created;
- checkout-created;
- pending;
- verification-pending;
- verified;
- failed;
- expired;
- mismatched;
- refunded.
Your registration or order may separately be:
- awaiting-payment;
- confirmed;
- cancelled;
- fulfilled.
That separation lets you represent the exact awkward case:
Customer completed the provider checkout, but our backend has not yet established a trustworthy verified payment.
You can then recover it without inventing history.
Bounded retries are useful; infinite faith is not
Verification can fail temporarily because of:
- network errors;
- provider latency;
- short-lived upstream failures;
- timeout conditions.
A small retry policy can be reasonable.
For example:
- verification attempt immediately;
- retry after a short delay;
- one final bounded retry;
- move to
verification_pendingor manual/reconciliation recovery.
The exact timing depends on your environment.
What matters is that retries are:
- bounded;
- observable;
- tied to the same transaction identity;
- safe to repeat.
Do not transform a provider outage into an infinite Worker loop.
Never ask for a second payment until you reconcile the first
This is the customer-safety rule.
If the provider flow looked completed but your verification failed, you do not yet know that the customer was not charged.
Before telling them to retry payment:
- inspect the gateway transaction dashboard/API;
- reconcile the reference;
- check webhook/event history;
- inspect the internal attempt;
- determine whether money actually moved.
A failed application verification is not proof of a failed charge.
The cost of getting this wrong is duplicate payment and damaged trust.
Use the webhook as another trusted signal, not a second source of chaos
Flutterwave can notify your backend about payment events independently of the customer’s browser journey.
That is valuable when the user closes the page or your callback route fails.
But the webhook handler must be idempotent.
If the callback verification and webhook both see the same successful transaction, your system should apply the business effect once.
A useful pattern is:
- locate internal payment attempt by trusted reference;
- verify/authenticate the provider signal;
- validate amount/currency/reference;
- acquire/update state atomically;
- if already verified, return success without duplicating effects;
- otherwise mark verified and trigger the business transition once.
This is the same principle I explain in why payment webhooks create duplicate orders.
Build a reconciliation path before you need it
A payment integration is incomplete if the only recovery option is “ask the customer to try again.”
Give support/admin a way to inspect:
- internal attempt ID;
- order/registration;
tx_ref;- provider transaction ID;
- expected amount/currency;
- provider verification result;
- webhook events;
- current internal state;
- timestamps;
- last verification error.
Then add a controlled server-side action to re-run verification for a specific pending transaction when appropriate.
That turns production incidents into recoverable operations instead of database surgery.
A debugging checklist for “completed but not verified”
When the customer returns from Flutterwave but your app will not confirm payment, I check this sequence:
- Record the exact browser callback parameters. Do not rely on screenshots alone.
- Identify the internal payment attempt. Which database row owns this transaction?
- Compare provider identifiers. Transaction ID and
tx_refmust not be confused. - Check credential/environment provenance. Was the checkout created under the same credential context now doing verification?
- Call verification server-side. Capture HTTP status and provider response safely.
- Expose the real error. Fix
{}logging before guessing. - Validate reference, final status, amount and currency. A provider success still has to match your business expectation.
- Inspect webhook/event history. Another trustworthy provider signal may already have arrived.
- Reconcile before asking for another payment. Protect the customer from duplicate charges.
- Make the recovery path idempotent. Reverification must not create the business effect twice.
The deeper production lesson
Payment bugs are rarely just API-call bugs.
They are state-consistency bugs across:
your order → your payment attempt → credential environment → provider checkout → provider transaction → callback/webhook → server verification → business effect.
The more of those boundaries you can observe explicitly, the faster you can diagnose the uncomfortable cases where the customer and the server appear to disagree.
If your app accepts payments but its verification, retries or recovery states still feel improvised, my MVP development service includes production hardening around payment state and backend integrations. You can also send me the failing transaction flow and I can help trace where the authoritative state diverges.



