The original version of this article became my most-clapped story on Medium, so here is the 2026 refresh: the same practical walkthrough, updated for the PayPal Orders API v2, modern Flutter, and the packages that actually work today.
So, in this article, I will be showing you how you can integrate PayPal as a payment gateway in your Flutter app.
For this purpose, we need to add these dependencies in your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
webview_flutter: ^4.8.0
intl: ^0.19.0
-
httpwill be used for calling the PayPal Orders API from your backend to create and capture orders. -
webview_flutterwill be used to show PayPal's secure approval page inside your app. -
intlhelps with formatting amounts correctly.
Let's jump into the coding part.
Step 1: Create the Order on Your Backend
You should never put your PayPal secret key inside a Flutter app — anyone can decompile it. Create the order on your server instead, then pass the orderId to the app.
Here is the flow:
Flutter App ──▶ Your Backend ──▶ PayPal Orders API
▲ │
└──── orderId ───────┘
In your backend (Node.js example), create the order:
// POST /api/paypal/create-order
const base = "https://api-m.paypal.com/v2/checkout/orders";
const response = await fetch(base, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${Buffer.from(
`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`
).toString("base64")}`,
},
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [{
amount: { currency_code: "USD", value: amount },
}],
}),
});
const order = await response.json();
res.json({ orderId: order.id }); // send this to the app
Don't use the given ID — you must replace it with your original one, you will receive it from your PayPal dashboard.
Step 2: Approve the Payment in the App
In Flutter, create a service that calls your backend, then launches PayPal's approval page in a WebView:
import 'package:http/http.dart' as http;
import 'package:webview_flutter/webview_flutter.dart';
Future<String> createOrder(double amount) async {
final res = await http.post(
Uri.parse('https://your-api.com/api/paypal/create-order'),
body: {'amount': amount.toString()},
);
// Parse and return orderId from your backend response.
return jsonDecode(res.body)['orderId'] as String;
}
Future<void> approveOrder(BuildContext context, String orderId) async {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('Pay with PayPal')),
body: WebView(
initialUrl: 'https://www.paypal.com/checkoutnow?token=$orderId',
javascriptMode: JavascriptMode.unrestricted,
navigationDelegate: (nav) async {
// PayPal redirects here after approval/cancel.
if (nav.url.contains('return_url')) {
Navigator.pop(context, 'approved');
} else if (nav.url.contains('cancel_url')) {
Navigator.pop(context, 'cancelled');
}
return NavigationDecision.navigate;
},
),
),
),
);
}
Step 3: Capture the Payment on the Backend
After the user approves, your backend captures the payment to actually move the money:
// POST /api/paypal/capture-order
const response = await fetch(`${base}/${orderId}/capture`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
});
const capture = await response.json();
// capture.status === "COMPLETED" → mark the order paid in your DB
A few details that trip people up here:
- The capture call must use a fresh access token (not the Basic auth string from step 1). PayPal issues access tokens via the
/v1/oauth2/tokenendpoint, and they expire after about 9 hours. Build a small token-cache in your backend and refresh it lazily — you do not want to create a new PayPal token on every request. - Capture responses come back with statuses like
COMPLETED,DECLINED, orPENDING(e.g., for e-checks). Only treatCOMPLETEDas payment success. Everything else should fall through to your "payment failed" state with a friendly message — never mark an order paid on anything else. - If the user approves but your app crashes before capture, the money is not taken. PayPal keeps the approved order alive for up to three hours, so you can retry the capture safely. Use an idempotency key (the
PayPal-Request-Idheader) so retries never double-charge.
Putting It Together: A Complete Payment Screen
Here is a minimal but complete widget that ties all three steps together — the sort of thing you can paste into a checkout screen today:
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class PayPalCheckoutButton extends StatefulWidget {
const PayPalCheckoutButton({super.key, required this.amount});
final double amount;
@override
State<PayPalCheckoutButton> createState() => _PayPalCheckoutButtonState();
}
class _PayPalCheckoutButtonState extends State<PayPalCheckoutButton> {
bool _processing = false;
Future<void> _startPayPalCheckout() async {
setState(() => _processing = true);
try {
// 1. Ask our backend to create the order.
final res = await http.post(
Uri.parse('https://your-api.com/api/paypal/create-order'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'amount': widget.amount}),
);
if (res.statusCode != 200) throw Exception('Could not create order');
final orderId = jsonDecode(res.body)['orderId'] as String;
// 2. Show PayPal's approval page.
final outcome = await approveOrder(context, orderId);
if (outcome != 'approved') {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Payment cancelled.')));
return;
}
// 3. Capture on the backend, then confirm to the user.
final cap = await http.post(
Uri.parse('https://your-api.com/api/paypal/capture-order'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'orderId': orderId}),
);
final captured = jsonDecode(cap.body)['status'] == 'COMPLETED';
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(captured ? 'Payment successful!' : 'Payment failed. Please retry.')));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Something went wrong. Please try again.')));
} finally {
if (mounted) setState(() => _processing = false);
}
}
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: _processing ? null : _startPayPalCheckout,
child: _processing
? const SizedBox(
width: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Pay with PayPal'),
);
}
}
A few notes on this widget:
- The
_processingflag disables the button while the flow runs, which stops double-taps — a real payment bug source I have seen more than once. - The catch block is intentionally generic. Do not leak error details (order IDs, stack traces) to the UI; log them server-side instead.
- You will want to read
orderIdfrom the backend response, not from the WebView URL — never trust the client for the money state.
The Full Flow at a Glance
1. App → POST /api/paypal/create-order (your backend, amount)
2. Backend → PayPal POST /v2/checkout/orders (client_id + secret, Basic auth)
3. Backend ← orderId (send to app)
4. App → WebView: paypal.com/checkoutnow?token=orderId (user logs in, approves)
5. PayPal → redirects to your return_url (app captures this)
6. App → POST /api/paypal/capture-order (your backend, orderId)
7. Backend → PayPal POST /orders/{id}/capture (access token)
8. Backend ← capture.status == "COMPLETED" → mark paid in DB
9. App shows success screen, clears the cart
That is the entire lifecycle. Nine steps, two of them on your server, one WebView in your app.
Common Mistakes (I See These in Comments Weekly)
-
Hardcoding the client secret in the app. The most common, the most dangerous. Anyone who can install your APK can extract it, and a leaked secret means anyone can create orders on your account. The app must never see a credential — only
orderIdvalues. - Trusting the app's return screen. A clever user can spoof the "approved" redirect. Never mark an order paid because the WebView said so — verify with the capture call on your backend.
-
Forgetting the currency. PayPal returns amounts as strings with a two-letter currency code. Parse them with
intl(that is why it is in the dependency list) instead of doing raw double math. -
Missing the webhook reconciliation. Capture calls can succeed on your side while PayPal's final state differs (e.g., disputes, refunds). Listen to
PAYMENT.CAPTURE.COMPLETEDandPAYMENT.CAPTURE.DENIEDwebhooks and reconcile against your database nightly. This saved one of my clients from a $3,000 refund discrepancy. -
Testing against the live API. Always develop against
https://api-m.sandbox.paypal.comwith sandbox buyer accounts. PayPal gives you test credit-card numbers in the sandbox dashboard — use them, then flip one environment variable to go live.
FAQ
Q: Do I need a PayPal Business account? A: Yes — personal accounts cannot create Orders API credentials. The upgrade is free and takes a few minutes.
Q: What if my app targets India? A: PayPal works, but for domestic Indian payments most of my clients pair it with UPI via Razorpay or Cashfree. PayPal is the right choice for international customers.
Q: Can I use flutter_paypal instead of a WebView? A: The old community packages are largely unmaintained and break with modern Flutter releases. The WebView + Orders API v2 approach uses only first-party, supported pieces — that is why this refresh avoids them entirely.
Q: How do I handle refunds? A: POST /v2/payments/captures/{capture_id}/refund with the capture ID you stored at payment time. Always store capture.id alongside the order.
Important Notes
- Never put your client secret in the app. The app only ever talks to your backend. This is the #1 mistake I see in comments on the original article.
-
Use
intent: "CAPTURE"for one-step payments, or"AUTHORIZE"if you need to authorize first and capture later (e.g., reserving funds). -
Handle webhooks. For production, listen to PayPal's
PAYMENT.CAPTURE.COMPLETEDwebhook to reconcile payments server-side — don't trust the app alone. -
Test in sandbox mode first with
https://api-m.sandbox.paypal.comand sandbox buyer accounts from your PayPal dashboard.
That's it — a complete PayPal integration in Flutter, the 2026 way. Same flow as the original post, modern stack, no obsolete packages, and every pitfall I have hit over six years of shipping payment flows across four continents documented above.
I have also written integrations for UPI/Razorpay and Stripe — comment below with your payment gateway and I'll cover it next.
*Gulshan Yad
Top comments (0)