So, in this article, I will be showing you how you can integrate Cashfree Payments into your Flutter app, step by step. Cashfree is a solid choice for Indian payments — first-class UPI, cards, and net banking, plus the strongest UPI Autopay story for subscriptions — and its Flutter SDK (cashfree_pg) is maintained and null-safe. The problem is the docs: the pieces are all there, but the order of operations gets buried, and the verification step that stops you from being scammed is easy to miss.
This is the integration done in the right order, exactly as I ship it: order created on your backend, checkout opened in the app, payment verified server-side, and webhooks for reconciliation. Let's jump into the coding part.
Add the Dependencies
Open your pubspec.yaml and add the official Cashfree Flutter SDK:
dependencies:
flutter:
sdk: flutter
cashfree_pg: ^1.1.2
Run flutter pub get. That is the only client-side dependency you need for the payment flow. Everything else you need (making HTTP calls, handling JSON) is built into Flutter and dart:convert.
A quick naming note: the package is cashfree_pg, published by Cashfree, not the community cashfree package you may see in old tutorials. Check the publisher on pub.dev before you pull — the unmaintained ones still show up in search results and have caused more than one "why is my SDK not working" ticket.
Step 1: Create the Order on Your Backend (Never in the App)
The same rule applies here as with every gateway, and Cashfree is no exception: your CLIENT_SECRET stays on the server. A Flutter binary can be decompiled, and a secret hardcoded in the app is a secret handed to anyone who downloads your APK. The app also must never decide the amount — if it does, a user can edit the request and pay less than you expect.
The flow looks like this:
Flutter App ──▶ Your Backend ──▶ Cashfree PG API
▲ │
└──── order_id ──────┘
On your backend (Node.js example), create the order:
// POST /api/cashfree/create-order
const CF_API = "https://api.cashfree.com/pg/orders";
const response = await fetch(CF_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-client-id": process.env.CF_CLIENT_ID,
"x-client-secret": process.env.CF_CLIENT_SECRET,
"x-api-version": "2025-07-08",
},
body: JSON.stringify({
order_amount: 149.0,
order_currency: "INR",
order_id: `order_${Date.now()}`, // unique per order
customer_details: {
customer_id: "user_123",
customer_phone: "9876543210",
},
}),
});
const order = await response.json();
// Send order.payment_session_id + order.order_id back to the app.
res.json({
orderId: order.order_id,
paymentSessionId: order.payment_session_id,
});
Two details to internalize now, because they are the two most common Cashfree integration bugs.
First, Cashfree's order API expects decimals for INR (order_amount: 149.0), unlike some gateways that want the minor unit in paise. Send 149.0, not 14900. Getting the amount unit wrong here is the #1 silent failure I see — the order is created, the checkout opens, and the amount looks wrong or the API rejects it in a way that is hard to trace.
Second, customer_details is required, not optional, in the 2025+ API version. Omit it and the order creation fails. customer_phone must be a valid 10-digit Indian number for the API version you are pinning; keep it in the header pin so a Cashfree API change cannot silently break your production endpoint.
The key value to capture is payment_session_id. That is what your app hands to the SDK — never the client secret, and never the raw order response.
Step 2: Open the Cashfree Checkout in the App
Back in Flutter, create a payment service that fetches the order from your backend and hands the session ID to the SDK:
import 'package:cashfree_pg/cashfree_pg.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class CashfreeService {
Future<Map<String, dynamic>> _createOrderOnBackend(double amount) async {
final res = await http.post(
Uri.parse('https://your-api.com/api/cashfree/create-order'),
body: {'amount': amount.toString()},
);
return jsonDecode(res.body) as Map<String, dynamic>;
}
Future<void> startCheckout(double amount) async {
final order = await _createOrderOnBackend(amount);
final drop = CFPaymentDropCheckoutSession(
paymentSessionId: order['paymentSessionId'] as String,
orderId: order['orderId'] as String,
orderAmount: amount,
orderCurrency: "INR",
orderNote: "Order from Flutter app",
customerId: "user_123",
customerName: "User Name",
customerEmail: "user@example.com",
customerPhone: "9876543210",
);
final response = await CashfreePGSDK.doPayment(drop);
if (response.paymentStatus == CFPaymentStatus.success) {
await _handleSuccess(response);
} else {
_handleFailure(response);
}
}
}
The CFPaymentDropCheckoutSession carries the order details the SDK needs to show the checkout — and crucially, paymentSessionId is the short-lived token Cashfree issued to your backend, not your client secret. The doPayment call returns a CFPaymentResponse with a paymentStatus you can switch on: success, cancelled, or failed.
A note on the checkout UI: Cashfree's drop-in checkout renders its own payment page (UPI, cards, net banking, wallets) inside your app. Customization is via theme and styling parameters on the session, not full control of the DOM. For most products that is the right trade — Cashfree maintains the PCI/security surface for you, and you should not be building card forms yourself.
Step 3: Verify the Payment on Your Backend
Here is the step the docs bury, and it is the one that keeps you from being defrauded. The paymentStatus == success callback in the app is not proof of payment — it is a client-side event that can be spoofed or delivered to a compromised app. The money is confirmed server-side, against Cashfree's API, using the orderId:
// GET /api/cashfree/order-status?orderId=...
const res = await fetch(`${CF_API}/${orderId}/payments`, {
headers: {
"x-client-id": process.env.CF_CLIENT_ID,
"x-client-secret": process.env.CF_CLIENT_SECRET,
"x-api-version": "2025-07-08",
},
});
const payments = await res.json();
const paid = payments.some((p) => p.payment_status === "SUCCESS");
if (paid) {
// Mark the order paid in your DB and grant entitlement.
}
Only a server-verified payment status of SUCCESS means the money is yours. Never grant access based on the app's callback alone.
Step 4: Add Webhooks for Reconciliation
For anything past a prototype, do not rely on the app callback or even the verify call alone. Register a webhook URL in the Cashfree dashboard and handle PAYMENT_SUCCESS events server-side:
// POST /api/cashfree/webhook
const event = req.body;
if (event.type === "PAYMENT_SUCCESS_WEBHOOK") {
const orderId = event.data.order.order_id;
// Verify signature, mark paid, reconcile.
}
Webhooks are the source of truth for reconciliation, especially for orders where the user's app crashed mid-flow or the network dropped after payment. Build idempotent handlers — the same event may be delivered more than once — and store the webhook signature verification key from your dashboard. If your server is down when an event fires, retry with backoff; do not assume single delivery.
Step 5: Wire the Result into Your UI Without the Classic Bugs
This is where integrations stop being a demo and start being an app. When doPayment returns, you need to update the UI, and there are three bugs I see repeatedly in Cashfree Flutter code.
Bug one: using a BuildContext across an async gap. Your await CashfreePGSDK.doPayment(drop) suspends the method, the user spends time inside the checkout, and when it returns, the widget may be gone. Calling Navigator.push or showing a dialog with a stale context throws a "used after disposal" error. Fix: check context.mounted before touching the context after the await:
Future<void> onPayPressed(BuildContext context) async {
final response = await _service.startCheckout(149.0);
if (!context.mounted) return;
if (response.paymentStatus == CFPaymentStatus.success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Payment successful')),
);
}
}
Bug two: treating "cancelled" as an error. A user tapping back out of the checkout is a normal event, not a failure. I have seen integrations log cancelled to error trackers and trigger retry flows that re-open the checkout in a loop. Fix: switch on all three statuses explicitly — success, cancelled, and failed — and give each a distinct message. Cancelled means "user changed their mind," which deserves a polite message and nothing else.
Bug three: trusting the callback instead of verifying. The success status is your trigger to call your backend's verify endpoint and show "done" only when the server confirms. If you show "payment successful" the moment the SDK says so and the verify call later fails, you have shown a success screen for an unconfirmed payment. The safe order: callback fires → verify on the server → update UI from the verification result. This is the discipline that separates a real integration from a screenshot demo.
A clean way to keep it organized: make startCheckout return the server-verified result rather than the raw SDK status.
Future<bool> payAndVerify(double amount) async {
final order = await _createOrderOnBackend(amount);
final session = CFPaymentDropCheckoutSession(
paymentSessionId: order['paymentSessionId'] as String,
orderId: order['orderId'] as String,
orderAmount: amount,
orderCurrency: "INR",
customerId: "user_123",
customerPhone: "9876543210",
);
final response = await CashfreePGSDK.doPayment(session);
if (response.paymentStatus != CFPaymentStatus.success) {
return false;
}
// Verify server-side before you claim success.
return _verifyOrderOnBackend(order['orderId'] as String);
}
Now the widget just does final paid = await payAndVerify(149.0); and shows the right screen. The verification lives where it belongs — inside the service — and the UI stays dumb.
The Complete Flow, In One Diagram
User taps "Pay" ──▶ App calls your /create-order
│
Your backend creates the Cashfree order → gets payment_session_id
│
SDK opens drop-in checkout (session ID, never secret)
│
User pays via UPI / card / netbanking
│
Success event (app) ──▶ /order-status verify (backend) ──▶ grant entitlement
│
PAYMENT_SUCCESS webhook ──▶ reconciliation (server)
Important Notes and Pitfalls
-
Amount unit. Cashfree's 2025+ API wants
order_amountin decimal INR (149.0). Do not convert to paise like some other gateways; that conversion is the #1 integration error. -
customer_detailsis required. Missing customer info fails order creation. Provide a valid 10-digitcustomer_phone. -
Secret never in the app. Only
payment_session_idtravels to the SDK. If you ever putx-client-secretin Flutter, anyone with the APK has your production credentials. -
Test vs live credentials.
testandprodenvironments have separate client IDs and secrets in the Cashfree dashboard. A classic bug: everything works with test keys, then the live build fails because the live key is missing a payment method or webhook. Configure your live dashboard before shipping. -
x-api-versionpinning. The API version header controls breaking behavior. Pin an explicit version (I use the latest documented one) so a Cashfree update does not change your endpoint behavior overnight. Check the current version in the Cashfree docs when you integrate — version numbers roll forward. -
Webhooks need idempotency. Events can be delivered more than once. Key your handler on
orderIdand store processed events so a duplicate does not double-grant. - Android setup. Ensure INTERNET permission (default in Flutter debug). If you test against an HTTP backend on a real device, allow cleartext traffic in debug builds only — never in release.
That is a complete Cashfree integration in Flutter, in the right order: order created server-side, drop-in checkout opened with a session token, payment verified against the API, and webhooks for reconciliation. If something breaks, check the amount unit first, then the customer_details block, then your API version header — in that order, one of those three is responsible for most first-integration failures.
I have also written integrations for PayPal, Stripe, and Razorpay (UPI) — comment below with your payment gateway and I'll cover it next.
*Gulshan Yad
Top comments (0)