The NPCI e-mandate flow for your Flutter app — subscription approval, first charge, and the amount limits that break real launches.
So, in this article, I will be showing you how you can implement UPI AutoPay — the recurring payment feature built on NPCI e-mandates — in your Flutter app.
A quick framing for anyone new to Indian payments. UPI AutoPay is NPCI's recurring payment system: your customer approves a mandate once inside their UPI app, and you can then debit their account on a schedule — daily, weekly, monthly, or on demand within the mandate's limits. No card numbers, no re-approval every month. This is how most subscription businesses in India collect money now, through gateways like Razorpay, Cashfree, and PayU. The Flutter side of the flow is identical across all of them, so the code below is gateway-agnostic.
For this purpose, we need to add these dependencies in your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
url_launcher: ^6.3.0
webview_flutter: ^4.8.0
uni_links: ^0.5.1
-
httpwill be used for calling your backend, which is the only thing that ever talks to the payment gateway. -
url_launcherwill be used to open the mandate approval deep link in the customer's UPI app (GPay, PhonePe, Paytm, and the rest). -
webview_flutterwill be used as a fallback for cases where deep links are unreliable. -
uni_linkswill be used to catch the deep link that the gateway redirects back to your app after the customer approves the mandate.
Let's jump into the coding part.
Step 1: Create the Mandate on Your Backend
Never put your gateway keys inside a Flutter app — anyone can decompile it, and gateway compliance audits will flag it. Create the mandate on your server, then pass the approval URL to the app.
Here is the flow:
Flutter App ──▶ Your Backend ──▶ Gateway API (mandate)
▲ │
└──── approval URL ──┘
In your backend (Node.js example), create the mandate:
// POST /api/upi/create-mandate
const mandate = await gateway.subscriptions.create({
plan_id: planId, // e.g. Razorpay plan for ₹499/month
customer_notify: 1,
notes: { userId: "u_1042" },
quantity: 1,
total_count: 12, // 12 monthly charges, then it stops
start_at: Math.floor(Date.now() / 1000) + 24 * 3600, // see Pitfall 1
expire_by: Math.floor(Date.now() / 1000) + 12 * 30 * 24 * 3600,
});
res.json({
subscriptionId: mandate.id,
approvalUrl: mandate.short_url, // send this to the app
});
The response contains two things the app needs: the subscription (mandate) ID and a short approval URL. That URL is a page the gateway hosts, which routes the customer into their UPI app to approve the mandate.
Step 2: Get the Approval URL in Flutter
In Flutter, create a service that calls your backend:
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<String> createMandate(double amount) async {
final res = await http.post(
Uri.parse('https://your-api.com/api/upi/create-mandate'),
body: {'amount': amount.toString()},
);
final body = jsonDecode(res.body) as Map<String, dynamic>;
return body['approvalUrl'] as String;
}
Step 3: Launch the Approval Flow
Now the important part. You want the customer to approve the mandate inside their UPI app, not in a browser. The gateway's approval URL handles the routing, but you should prefer an external app launch and only fall back to a WebView when deep linking fails:
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
Future<void> approveMandate(BuildContext context, String approvalUrl) async {
final uri = Uri.parse(approvalUrl);
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (launched) return; // customer is now in their UPI app
// Fallback: show the gateway's hosted page in a WebView
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('Approve AutoPay')),
body: WebView(
initialUrl: approvalUrl,
javascriptMode: JavascriptMode.unrestricted,
),
),
),
);
}
Some banks' UPI apps do not handle deep links reliably, which is why the WebView fallback exists. In both cases the customer ends the flow by entering their UPI PIN and approving the mandate — or cancelling.
Step 4: Handle the Return to Your App
When the customer finishes inside their UPI app — approving or cancelling — the gateway redirects back to your app through the deep link you registered when setting up the mandate flow. In Flutter you intercept that with uni_links. Register a custom scheme (like myapp://) in your AndroidManifest.xml and Info.plist, then listen for the return:
import 'package:uni_links/uni_links.dart';
void initDeepLinkListener() {
uriLinkStream.listen((Uri? uri) {
if (uri == null) return;
// e.g. myapp://upi-return?subscription_id=sub_xxx&status=success
final subscriptionId = uri.queryParameters['subscription_id'];
final status = uri.queryParameters['status'];
if (status == 'success' && subscriptionId != null) {
fetchMandateStatus(subscriptionId); // confirm server-side
}
});
}
The query parameters are gateway-specific — read your gateway's docs to confirm the exact shape. The important habit is the last line: the deep link only tells you the customer returned. Whether the mandate actually went live is a server-side question, and that is exactly what the next step answers.
Step 5: Confirm the Mandate Status
After the customer returns to your app, do not trust the client-side result. Fetch the mandate status from your backend, which asks the gateway for the source of truth:
Future<Map<String, dynamic>> fetchMandateStatus(String subscriptionId) async {
final res = await http.get(
Uri.parse('https://your-api.com/api/upi/mandate/$subscriptionId'),
);
return jsonDecode(res.body) as Map<String, dynamic>;
// { status: "ACTIVE" | "PENDING" | "CANCELLED" }
}
A common trap: the customer approves the mandate, the app shows a success toast, and then the first charge silently fails because the mandate never went live. Always confirm server-side before you show the "subscribed" screen.
Step 6: Let Webhooks Own the Money State
The first charge, every recurring charge, revocations, and failures arrive at your backend as webhooks from the gateway — never in the app. The backend verifies the signature, records the payment, and pushes the result to the app (via push notification, a WebSocket, or a refresh on next screen load). I have written a separate deep dive on handling payment webhooks the right way, and the same rules apply here: verify signatures, make handlers idempotent, and treat webhooks as the only source of truth for money movement.
Reconciliation: The Part Everyone Skips
You can build the entire flow above and still lose money if you skip reconciliation. Here is the discipline I now build into every recurring-payment client, and it is not optional:
- A mandates table. Store every mandate with its status, creation date, first-charge date, and amount. You need this to know what you are supposed to collect.
- A charges ledger. Append every webhook event — success, failure, retry — to a per-mandate ledger. The ledger is what you reconcile against, not the dashboard.
- A daily reconciliation job. Every morning, compare what should have been collected against what the gateway reports. A mismatch is a bug, a revoked mandate, or a gateway issue — and you want to find it in a script, not a customer complaint.
- Alert on drift. If a mandate that used to collect starts failing silently, alert. NPCI retries failed debits for you, but retries are not infinite, and a silently dead subscription is lost revenue you can often recover with one reminder.
The gateways give you APIs to query mandates and payments. Use them. A monthly reconciliation habit has caught two real gateway-side settlement bugs for my clients — bugs that support tickets would have surfaced weeks later.
Important Notes and Pitfalls
-
First charge timing. NPCI e-mandates cannot be charged immediately. The first debit must be at least 24 hours after the mandate is created for standard mandates (low-value ones can be faster). Schedule
start_ataccordingly or your first collection silently fails. - The ₹15,000 cap. Standard e-mandates without Aadhaar authentication are capped at ₹15,000 per mandate for most categories. A ₹25,000-per-month subscription will fail at approval time. You will need additional authentication or a different instrument above that.
- Fixed vs. on-demand mandates. Use a scheduled mandate for fixed recurring amounts (monthly subscription). Use an on-demand mandate when the amount varies — usage billing, top-ups, pay-per-use. Switching modes later means creating a new mandate and re-approval.
- Revocation. Customers can revoke a mandate inside their UPI app at any time. Handle the revocation webhook and stop charging immediately — charging a revoked mandate triggers chargebacks and gateway penalties.
- Retries. NPCI retries failed debits for you, but you still need to reconcile. Track each charge attempt server-side and alert when a customer's mandate stops collecting.
- Never trust the app. The app can only request a mandate and report a return. Money state lives in webhooks and gateway API calls, verified server-side.
- Test mode quirk. Sandbox environments still enforce the 24-hour first-charge rule. Plan your test data so you are not surprised when the test subscription does not charge immediately.
Frequently Asked Questions
Do I need a payment gateway at all? You can integrate NPCI's e-mandate API directly, but you need to be a bank partner or licensed aggregator. For everyone else, a gateway handles the compliance, the mandate lifecycle, and the webhooks. Use the gateway.
My customer uses GPay but the approval opens PhonePe. The gateway's approval page shows the UPI apps installed on the device and lets the customer choose. You do not route to a specific app yourself — and you should not try, because you cannot reliably detect which UPI apps are installed.
What about the one-time UPI payments flow I already have? That is a different flow (the upi://pay deep link without a mandate). Recurring collections need the mandate flow above. If your codebase handles both, keep them in separate services — they have different lifecycles and different webhooks.
How long does approval actually take? The customer approves in seconds inside their UPI app — it is one PIN entry. The mandate status moves to ACTIVE almost immediately after approval, but the first charge is governed by the 24-hour NPCI rule, not by the approval moment. Expect the first debit to land a day after the customer approves, at the earliest.
Can a customer pause or cancel a mandate? Yes, from inside any UPI app, at any time. There is no "pause" flag — cancelling is binary, and you learn about it via the revocation webhook. Your reconciliation job is what catches it if the webhook is slow or missed.
Do I need a UPI deep link in my app at all? No. The gateway's approval URL does the routing. You only need a return deep link so the app knows the customer is back. Some gateways also support a "waiting" screen that polls status server-side instead of using deep links — that is a fine alternative if you want to avoid deep-link setup.
What if the customer's UPI app crashes mid-approval? The mandate is not created until the customer approves and confirms the PIN. If they crash before that, the mandate stays PENDING or is simply never created, and your confirmation step will report the truth. Never show success based on the deep link alone.
That's it — a complete UPI AutoPay integration in Flutter. Same flow works whether you bill monthly, weekly, or on demand, and the gateways handle the NPCI compliance for you.
I have also written integrations for PayPal, Stripe, and one-time UPI/Razorpay — comment below with your payment flow and I'll cover it next.
*Gulshan Yad
Top comments (0)