DEV Community

Gulshan Yadav
Gulshan Yadav

Posted on • Originally published at misar.blog

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
Enter fullscreen mode Exit fullscreen mode

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 ──────┘
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode

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.
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 StatefulWidget whose dispose() removes the event handlers), the callback can be lost mid-flow. Keep the PaymentService instance 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

  1. Amount units. Razorpay uses the minor unit — paise for INR. 149.00 rupees must be 14900 paise. There is no decimal support; sending a float throws an error.
  2. Key security. The key_secret never enters the app. Only key_id is safe to ship, and even that belongs in a configurable place so you can swap between test and live keys without rebuilding.
  3. Test vs live keys. Use key_test_* and key_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.
  4. 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.
  5. The order_id is mandatory. If you open the checkout without an order_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.
  6. Webhooks over callbacks. For anything past a prototype, verify via Razorpay's payment.captured webhook 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.
  7. 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)
Enter fullscreen mode Exit fullscreen mode

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.

FAQ

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.

Is payment_capture: 1 safe? Yes for a normal storefront. It captures money automatically when the payment succeeds. If you need to hold funds for approval (marketplaces, services delivered later), switch to manual capture and capture explicitly — but then you own the capture schedule.

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.

I have also written integrations for PayPal, Stripe, and Cashfree — comment below with your payment gateway and I'll cover it next.


*Gulshan Yad

Top comments (0)