The bug report usually reads like this: "Customer says they paid, money left their account, app is still spinning." You check the Razorpay Dashboard and the payment is captured. Your Flutter app never got EVENT_PAYMENT_SUCCESS, so the order is still pending and the customer is on the phone. A Razorpay Flutter callback not received is one of the most common payment bugs we see, and the honest answer is that you can reduce it but never eliminate it. The real fix is making sure it doesn't matter.
This post covers why callbacks go missing, the fixes worth doing in the app, and the backend design that makes a lost callback a non-event. The backend half applies just as much to React Native and web checkouts.
Razorpay Flutter callback not received: the usual causes
The razorpay_flutter plugin opens Razorpay's checkout and reports back through three events: EVENT_PAYMENT_SUCCESS, EVENT_PAYMENT_ERROR and EVENT_EXTERNAL_WALLET. Anything that breaks the path between the native checkout and your Dart listener loses the result. The usual suspects:
- The listener's owner is gone. The
Razorpayinstance lives in a widget'sState, the user navigates or the widget rebuilds, anddispose()callsclear(), which removes all listeners. The payment finishes and nobody's listening. - Android killed your app during the UPI handoff. The user switches to their UPI app, approves, and on a low-memory phone Android has reclaimed your process in the meantime. When they come back, your app restarts fresh. The callback that belonged to the old process doesn't exist any more.
- The user closed the app after approving in the UPI app, or the phone lost network on the way back.
- Handler signature mismatch. The plugin's docs warn that callbacks must match the expected response types. A handler typed wrongly can throw before your code runs.
- A second
Razorpay()instance created on the retry path, with listeners attached to the first.
Only the first and last two are code bugs. Process death and closed apps are just how phones work.
Fixes worth making in the app
Keep the Razorpay instance in something that outlives a single screen, such as a payment service or a BLoC provided above your checkout routes, and register listeners once:
final razorpay = Razorpay();
razorpay.on(Razorpay.EVENT_PAYMENT_SUCCESS, _onSuccess); // void _onSuccess(PaymentSuccessResponse r)
razorpay.on(Razorpay.EVENT_PAYMENT_ERROR, _onError); // void _onError(PaymentFailureResponse r)
razorpay.on(Razorpay.EVENT_EXTERNAL_WALLET, _onWallet); // void _onWallet(ExternalWalletResponse r)
Call clear() when that service is torn down, not when a checkout screen is.
Then, more important, write the pending order ID to local storage before calling open(). On app start and on resume, if a pending order ID exists, ask your backend what happened to it. That one check covers process death, closed apps and dropped networks, because it doesn't depend on the callback at all.
Finally, treat the success callback as a hint. PaymentSuccessResponse gives you paymentId, orderId and signature (the signature is only present when you used an Order). Send all three to your server and show "confirming payment" until it answers.
Server verification: the signature check
Always create a Razorpay Order on your server first and pass its ID to checkout. That's what makes the signature check possible, and it means the amount comes from your server rather than the app.
When the app reports success, verify the signature on the server. Razorpay's signature is an HMAC-SHA256 of order_id + "|" + razorpay_payment_id, keyed with your API key secret:
const expected = crypto
.createHmac('sha256', process.env.RAZORPAY_KEY_SECRET)
.update(`${orderId}|${paymentId}`)
.digest('hex');
const valid = expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
The order ID here is the one from your database, not the one the app sent back. Your key secret never goes in the app. If the check passes, mark the order paid with a conditional update (WHERE status = 'pending') so it happens exactly once.
Webhooks as the source of truth
The signature check only runs if the app reports back. For the cases where it doesn't, Razorpay webhooks reach your server directly. Subscribe to payment.captured and order.paid, and in the handler:
- Verify
X-Razorpay-Signatureagainst the raw body with your webhook secret (a separate secret from the API key). - Deduplicate on the
x-razorpay-event-idheader, since duplicate deliveries are expected. - Return a 2xx within 5 seconds and do the work asynchronously.
- Run the same "pending to paid" conditional update that the signature path uses. Whichever arrives first wins; the other is a no-op.
Then add a safety net for when both miss: a scheduled job that takes orders pending for more than a few minutes and calls Razorpay's API to fetch payments for that order. If there's a captured payment, mark it paid through the same function. When the app asks about a pending order on resume, the backend can run that same check on demand.
Also check your capture settings in the Dashboard. A payment that's authorised but never captured isn't money you keep, and Razorpay refunds uncaptured payments after a window. Auto-capture on orders avoids that class of problem.
With this in place, a missing callback costs the customer a few seconds of "confirming payment" instead of a support call. We did this kind of work on a native Razorpay integration in Flutter for consumer fintech and commerce products, where it gave a measurable lift in successful mobile payments. If your app has payments stuck in pending, orithLabs can find out why and fix the flow as part of our mobile development work. The first call is free.