UPI Payment Gateway in Flutter (Razorpay) — The Complete 2026 Guide

So, in this article, I will be showing you how you can integrate UPI payments in your Flutter app using Razorpay. By the end, you will have a working payment flow — UPI, cards, and net banking — with the app talking to your own backend for order creation and verification, exactly the way you should be building it in production.
Razorpay is the most-used payment gateway for UPI in India for a reason: it supports UPI, credit and debit cards, net banking, and wallets through a single SDK, and it handles the entire UPI redirect dance for you — the "collect on this app," "open the UPI app," and "wait for the mandate" steps that make UPI painful to build yourself. In 2026 the SDK is stable, null-safe, and actively maintained, which is more than I can say for several gateways I have integrated.
Let's jump into the coding part.
Add the Dependency
Open your pubspec.yaml and add the official Razorpay Flutter SDK:
dependencies:
flutter:
sdk: flutter
razorpay_flutter: ^1.3.7
That is the only third-party package you strictly need for the client side. Run flutter pub get.
A note on the package name: you will sometimes see razorpay_flutter mixed up with the older, unmaintained community package. Make sure you use the official one from the Razorpay organization — the version number I gave resolves to the maintained SDK. If your pub get pulls a package that has not been updated in three years, you have the wrong one.
Step 1: Create the Order on Your Backend (Never in the App)
Here is the rule that decides whether your integration is a demo or a product: the app never holds your key_secret, and the app never decides the amount. Both live on your backend. Anyone can decompile a Flutter app and pull a hardcoded secret out of the binary, and if the app decides the amount, a user can pass their own amount into the request. I have seen both mistakes in production apps, and both end with someone's money or someone's trust gone.
The correct flow looks like this:
Flutter App ──▶ Your Backend ──▶ Razorpay Orders API
▲ │
└──── order_id ──────┘
On your backend (Node.js example), create an order:
// POST /api/create-order
const Razorpay = require("razorpay");
const rzp = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID,
key_secret: process.env.RAZORPAY_KEY_SECRET,
});
const order = await rzp.orders.create({
amount: 14900, // paise, always the minor unit
currency: "INR",
receipt: "rcpt_plan_001",
payment_capture: 1, // auto-capture on payment
});
res.json({ orderId: order.id, amount: 14900, currency: "INR" });
Two things to internalize now. First, amount is in paise — 14900 paise is 149 rupees. Razorpay rejects floats, and every first-time integrator hits this at least once. Second, payment_capture: 1 captures money automatically when the payment succeeds; if you want to hold funds for approval (marketplaces, escrow, pre-paid services), set it to 0 and capture manually via the Payments API later.
Step 2: Initialize the SDK and Open the Checkout
Back in Flutter, create a payment service that fetches the order from your backend, then opens Razorpay's built-in checkout UI:
import 'package:razorpay_flutter/razorpay_flutter.dart';
class PaymentService {
late final Razorpay _razorpay;
PaymentService() {
_razorpay = Razorpay();
_razorpay.on(Razorpay.EVENT_PAYMENT_SUCCESS, _handleSuccess);
_razorpay.on(Razorpay.EVENT_PAYMENT_ERROR, _handleError);
_razorpay.on(Razorpay.EVENT_EXTERNAL_WALLET, _handleExternalWallet);
}
Future<void> startCheckout(double amountInRupees) async {
// 1. Get a fresh order from YOUR backend (order_id, key_id, amount).
final order = await _fetchOrderFromBackend(amountInRupees);
final options = {
'key': order['keyId'], // your public key id, safe to share
'amount': order['amount'], // in paise, straight from the server
'name': 'Your Business Name',
'description': 'Order #${order['receipt']}',
'order_id': order['orderId'],
'prefill': {
'contact': '9876543210',
'email': 'user@example.com',
},
'theme': {
'color': '#0A66C2',
},
};
try {
_razorpay.open(options);
} catch (e) {
// Razorpay throws if options are incomplete or the SDK is misconfigured.
debugPrint('Failed to open checkout: $e');
}
}
void _handleSuccess(PaymentSuccessResponse response) {
// paymentId + orderId — send BOTH to your backend for verification.
}
void _handleError(PaymentFailureResponse response) {
// code 0 = user cancelled. Don't log it as an error; log the code.
}
void _handleExternalWallet(ExternalWalletResponse response) {
// User chose PhonePe / Google Pay / Paytm via the wallet entry point.
}
}
Registering the event handlers inside the constructor keeps them alive for the widget's lifetime, which is a common source of "my success callback never fires" bugs — if you create a new Razorpay() inside the method that opens checkout and then expect a callback later, the handlers are gone.
The order_id from your backend is what ties everything together. Never pass an amount you computed in the app; the checkout should show the amount your server authorized, or a user can edit the request and pay less.
Step 3: Verify the Payment on Your Backend
This is the step most tutorials skip, and it is the step that keeps you from being scammed. The payment_success callback in the app is not proof of payment — it is a mobile event that can be spoofed or simply delivered to a compromised app. Verification happens server-side, against Razorpay's API, using the paymentId:
// POST /api/verify-payment
const payment = await rzp.payments.fetch(paymentId);
// payment.status === "captured" → mark the order paid in your DB
Only trust a status of captured (or authorized if you used manual capture). A payment with status failed or refunded means the money is not yours. And once the DB says paid, grant the entitlement — do not trust any client-side signal.
Step 4: Handle UPI Deep Links Correctly
Here is where UPI gets special, and where most Flutter integrations break. When a user pays with UPI, Razorpay's checkout shows the list of UPI apps and hands off to the one they choose. When the user returns to your app, the checkout flow resumes and fires the success or failure event. Two things to get right:
- On Android, if you launched the checkout from a screen that gets recreated (a
StatefulWidgetwhosedispose()removes the event handlers), the callback can be lost mid-flow. Keep thePaymentServiceinstance above the widget that opens checkout — a service-level singleton, not a widget-scoped one. - Do not listen to app lifecycle events to decide success. Users genuinely switch away from your app to approve the payment in their bank's UPI app, and then return. If your code treats "app went to background" as "payment failed," you will mark real payments as failed. Let the Razorpay success/error events, which fire on return, be the source of truth on the client.
Important Notes and Pitfalls
- Amount units. Razorpay uses the minor unit — paise for INR.
149.00rupees must be14900paise. There is no decimal support; sending a float throws an error. - Key security. The
key_secretnever enters the app. Onlykey_idis safe to ship, and even that belongs in a configurable place so you can swap between test and live keys without rebuilding. - Test vs live keys. Use
key_test_*andkey_live_*from your Razorpay dashboard. A very common bug: everything works with test keys, then the live integration fails silently because the live key was not enabled for the payment methods you are offering. Enable UPI and the card networks you need in the dashboard before you ship. - Sandbox cards and UPI. Razorpay's test mode provides test card details in the docs. For UPI in test mode, use the test VPA (virtual payment address) listed in your Razorpay test mode settings — real UPI apps will not pay test orders.
- The
order_idis mandatory. If you open the checkout without anorder_id, Razorpay still works, but you lose the ability to verify which payment belongs to which order, and your reconciliation becomes guesswork. Always create the order server-side first. - Webhooks over callbacks. For anything past a prototype, verify via Razorpay's
payment.capturedwebhook server-side in addition to the on-device callback. The webhook is the source of truth for reconciliation; the app callback is just UX. If your server is down when the webhook fires, build retries — do not rely on one delivery. - Permissions. Razorpay's Android setup needs the INTERNET permission (present by default in Flutter). If you use their web checkout fallback, ensure you are not blocking cleartext traffic in your debug builds when testing with HTTP endpoints.
The Complete Flow, In One Diagram
User taps "Pay" ──▶ App calls your /create-order
│
Your backend creates the Razorpay order (amount in paise)
│
SDK opens checkout (key_id + order_id, never secret)
│
User pays via UPI / card / netbanking
│
Success event (app) ──▶ /verify-payment (backend)
│ │
payment.status === 'captured' ──▶ grant entitlement
│
webhook payment.captured ──▶ reconciliation (server)
That is a complete, production-shaped UPI integration in Flutter with Razorpay: order created server-side, checkout opened with the official SDK, payment verified on the backend, and webhooks for reconciliation. The whole thing runs in under 20 seconds from tap to captured, and every moving part is verifiable.
If you hit a wall, the two places to look first are the amount unit (paise, always) and the order of operations (create order, then open checkout, then verify). Get those three right and the rest is cosmetics.
Alternative Approaches Worth Knowing
The flow above is the one I ship by default, but there are two variations you will see in the wild, and you should know why I do not use them for production.
Razorpay's _handleSuccess from a hardcoded key. Plenty of tutorials open the checkout with just a key and an amount, skipping the server-side order entirely. It works, the checkout opens, and for a demo that is fine. The moment real money is involved it is wrong, because there is no order_id binding the payment to a purchase, and the amount you pass can be edited. Always create the order server-side first — the extra round trip is a few hundred milliseconds and it is the difference between verifiable payments and guesswork.
The payment_capture: 0 manual-capture route. If you run a marketplace or an escrow model, you set payment_capture: 0 at order creation and capture funds later via the Payments API once you release the money. This is a real feature, not a workaround — just be aware you now own the capture lifecycle, including the Razorpay capture window, and a capture you forget is a payment that never settles. Use auto-capture unless you have a concrete reason not to.
Handling UPI Intent and Error Codes
When a user selects UPI in Razorpay’s checkout, the SDK launches a native UPI intent that communicates with the chosen UPI app. The intent returns a bundle containing status, transaction ID, and a signature. Your Flutter code must parse this bundle in the onSuccess callback and verify the signature against the order ID and amount. Failure paths are handled in onError, where the SDK provides an error code and description. Common error codes include 0x00000001 for user cancellation, 0x00000002 for network failure, and 0x00000003 for insufficient balance. Handling each code explicitly improves user experience.
The error response also contains a transaction_id that can be cross‑checked with the Razorpay server. If the transaction ID is missing, treat the payment as failed. This double‑layer validation protects against spoofed responses.
To streamline error handling, create a helper function that maps error codes to user‑friendly messages. For example:
String _errorMessage(int code) {
switch (code) {
case 1:
return 'Transaction cancelled';
case 2:
return 'Network error, please retry';
case 3:
return 'Insufficient balance';
default:
return 'Unexpected error, contact support';
}
}
Integrating this helper into the onError callback ensures consistent messaging across the app.
Server‑Side Verification and Webhook Setup
Razorpay’s backend sends a webhook to a URL you configure in the dashboard whenever a payment’s status changes. The payload includes payment_id, order_id, status, and a signature header. Your server must verify the signature using the secret key provided by Razorpay. The verification algorithm is HMAC SHA‑256 over the concatenated order_id and payment_id.
A typical verification flow:
- Receive the POST request.
- Extract the
x-razorpay-signatureheader. - Compute the HMAC of the concatenated payload.
- Compare the computed hash with the header.
- If they match, update the order status; otherwise, reject.
Store the secret key in environment variables and never expose it in client code. Use a lightweight framework such as Express, Flask, or FastAPI to implement the endpoint. Log the raw payload and verification result for audit purposes.
Webhooks provide real‑time status updates even if the user closes the app before the payment completes. This ensures your order state remains accurate.
Securing API Keys and Transaction Data
API keys are the gateway to your Razorpay account. Exposing them in client code can lead to unauthorized charges. Secure storage solutions vary by platform:
- Android: Android Keystore via
flutter_secure_storage. - iOS: iOS Keychain via the same plugin.
- Web: Environment variables in CI/CD pipelines.
Never commit keys to Git repositories. Use a .gitignore entry for any file that contains keys. In CI pipelines, inject keys as secrets and load them into the app at build time.
Transaction data should be stored in a relational database with proper encryption at rest. Only keep non‑sensitive fields in logs. For audit, maintain a separate immutable log store. Rotate logs regularly and purge data older than the retention policy.
Use HTTPS for all network requests and enforce TLS 1.2 or higher. Disable HTTP in your Flutter HttpClient configuration to avoid accidental clear‑text traffic.
Customizing the Checkout UI and Fallback Strategies
Razorpay provides a default UI that is lightweight and consistent across platforms. However, many merchants want a branded experience. The SDK allows you to pass custom options:
theme.colorto match your brand palette.prefill.emailandprefill.contactto auto‑populate fields.external.walletsto limit or prioritize specific wallets.
If the user’s device lacks a UPI app, Razorpay falls back to a web view. You can intercept this by listening to the onExternalWallet callback and displaying a custom prompt. For a fully native experience, consider implementing a QR code scanner that opens the UPI intent directly, bypassing Razorpay’s fallback.
When designing the UI, keep the following in mind:
- Show a loading indicator during network calls.
- Disable the payment button after submission to prevent duplicate requests.
- Provide clear error messages and a retry option.
Sandbox Testing and Test UPI IDs
Before going live, test every edge case in Razorpay’s sandbox. The sandbox environment mirrors production but uses a separate set of keys. Create a test UPI ID (e.g., 9876543210@upi) and add it to your device’s wallet. Use this ID to simulate:
- Successful payments.
- User cancellations.
- Insufficient balance.
- Network interruptions.
Razorpay’s dashboard offers a “Test Mode” toggle. When enabled, all payments are simulated and no real money is transferred. Additionally, you can use the razorpay_checkout test mode flag in the SDK to force the sandbox environment.
After testing, run a full regression against your server’s webhook handler. Verify that signatures match and that the order status updates correctly.
Debugging Common Pitfalls and Performance Tips
Common issues include:
- Missing intent filter: Ensure the UPI intent filter is present in AndroidManifest.xml; otherwise, the checkout will crash.
- Signature mismatch: Verify that the server’s secret key matches the one in the dashboard.
- Duplicate payments: Use the
payment_idas a unique identifier to prevent double‑charging. - Timeouts: Increase the HTTP client timeout if the user’s network is slow.
Performance improvements:
- Cache the RazorpayCheckout instance to avoid repeated initializations.
- Use async/await to keep the UI responsive.
- Debounce the payment button to prevent accidental double taps.
By following these guidelines, you can create a robust, secure, and user‑friendly UPI payment flow in Flutter using Razorpay.
Key Takeaways
- Integrate Razorpay’s Flutter SDK by adding the dependency and configuring AndroidManifest.xml and Info.plist for UPI intent handling.
- Use the RazorpayCheckout widget to launch the UPI flow and implement onSuccess, onError, and onExternalWallet callbacks for complete transaction lifecycle.
- Validate the response payload—check status, payment_id, and order_id—before updating the order status on your server to avoid double‑charging.
- Store Razorpay API keys in Flutter’s secure storage or platform keychain and never commit them to version control; use environment variables during CI.
- Test all UPI scenarios in Razorpay’s sandbox by creating a test UPI ID and enabling test mode to simulate success, failure, and cancellation.
Frequently Asked Questions
Does this work for international users?
Razorpay is built for India. For international cards you will typically route through a different gateway; this integration covers the Indian consumer flow (UPI, cards, net banking) that is the 90% case for an India-first product.
Do I need a registered business?
Yes. To activate live payments you need a business entity with GST and KYC approved in the Razorpay dashboard. Build and test with the test keys while the approval runs — that is the whole point of test mode.
Why does the success callback sometimes not fire?
The two usual causes: you created a new Razorpay() instance inside the method that opened the checkout (so the handlers are gone), or a widget's dispose() removed them mid-flow. Keep one service-level instance and register handlers once.
Can I style the checkout?
Razorpay's checkout is their UI with a theme color you pass in options. For full control of a branded checkout you would move to the checkout flow API and render the fields yourself — a larger project, and one that puts PCI scope on you. For most products the built-in checkout is the right trade.
1 followers
AI systems builder · 7 years in production. RAG, self-hosted infra, agent architecture. 📬 Deep-dives → mrgulshanyadav.substack.com




Comments
Sign in to join the conversation
No comments yet. Be the first to share your thoughts!