mobile-checkout
Guide for implementing mobile in-app checkout with Dodo Payments across React Native, Flutter, iOS, and Android platforms.
它会碰到什么
这一栏是扫描器报的事实,不是结论。命中多不等于有毒(安全工具、规则库、示例脚本本来就会包含危险写法),命中少也不等于干净。它和你手上的凭据、文件、网络有什么关系,需要你自己看。
技能内容
Mobile In-App Checkout
This skill covers integrating Dodo Payments hosted checkout into native and cross-platform mobile apps using secure system browser contexts.
When to use this skill
- Building a React Native app with Turbo Module checkout integration
- Adding checkout to a Flutter app via native bridge
- Implementing native iOS or Android checkout with secure browser contexts
- Registering custom URL schemes and deep links for payment return
- Handling abandoned checkout sessions and recovery flows
- Confirming payment authority server-side before granting access
Core principle: Backend creates, mobile opens
Your backend creates the checkout session and returns a URL. The mobile app opens that URL in a secure browser context. Your API key must never be embedded in the app binary. The mobile SDK result is informational only; always verify the payment server-side via webhook or API before unlocking features or granting access.
Architecture overview
- Backend: Create a checkout session via
client.checkoutSessions.create(...)and return thecheckout_urlto your mobile app. - Mobile app: Call the platform-specific SDK with the checkout URL and a registered return URL scheme.
- Browser context: The SDK opens the URL in a secure system browser: SFSafariViewController on iOS and Chrome Custom Tabs on Android.
- Return: After payment, the browser navigates to your return URL. The SDK captures the result and passes it to your app.
- Verification: Query the checkout session or listen for a webhook to confirm the payment before granting access.
React Native (Turbo Module)
Installation
npm install @dodopayments/react-native-checkout
For Expo projects, add the plugin to app.json:
{
"expo": {
"scheme": "myapp",
"plugins": [
[
"@dodopayments/react-native-checkout",
{ "scheme": "myappcheckout" }
]
]
}
}
The plugin registers a custom URL scheme (myappcheckout://) that the checkout flow uses to return to your app.
Setup
Register the URL listener at app startup:
import { Linking } from 'react-native';
import { DodoCheckout } from '@dodopayments/react-native-checkout';
// Required for iOS return-URL handling
Linking.addEventListener('url', ({ url }) => DodoCheckout.handleOpenURL(url));
Starting checkout
const result = await DodoCheckout.start({
checkoutUrl: 'https://checkout.dodopayments.com/...', // from your backend
returnUrl: 'myappcheckout://checkout/return', // must match registered scheme
onEvent: (e) => console.log(e.type), // optional event logging
});
switch (result.status) {
case 'succeeded':
// Payment succeeded. Verify server-side before granting access.
await verifyPaymentOnBackend(result.paymentId);
showSuccess();
break;
case 'failed':
// Payment failed. Show error to user.
showFailure();
break;
case 'cancelled':
// User cancelled. Dismiss checkout.
dismiss();
break;
case 'pending':
// Payment is pending (e.g., awaiting 3D Secure). Show waiting state.
showPending();
break;
case 'expired':
// Checkout session expired. Prompt user to start a new checkout.
showExpired();
break;
}
Abandoned session recovery
If the app crashes or is backgrounded during checkout, recover the session:
import { DodoCheckout } from '@dodopayments/react-native-checkout';
const abandoned = await DodoCheckout.getAbandonedSession();
if (abandoned) {
// Reconcile abandoned.sessionId with your backend
// Decide whether to resume or start fresh
await DodoCheckout.clearAbandonedSession();
}
Android minSdk requirement
React Native checkout requires Android minSdk 24 or higher. Note: the general mobile documentation mentions minSdk 23, but React Native specifically requires 24.
Flutter
Installation
Add the Dodo Payments Flutter package to pubspec.yaml:
dependencies:
dodopayments_checkout: ^1.0.2
Setup
The package uses DodoCheckout.instance. On iOS, register the return URL scheme and forward incoming links from your deep-link listener. Run flutter pub add app_links if you use the app_links approach shown here:
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:dodopayments_checkout/dodopayments_checkout.dart';
late final StreamSubscription<Uri> checkoutLinkSubscription;
void listenForCheckoutReturns() {
checkoutLinkSubscription = AppLinks().uriLinkStream.listen((uri) {
unawaited(DodoCheckout.instance.handleOpenURL(uri.toString()));
});
}
Start the listener from your root state object's initState and cancel checkoutLinkSubscription from dispose. handleOpenURL is required on iOS and safely returns false on Android.
Starting checkout
import 'package:dodopayments_checkout/dodopayments_checkout.dart';
final result = await DodoCheckout.instance.start(
CheckoutParams(
checkoutUrl: Uri.parse('https://checkout.dodopayments.com/...'),
returnUrl: Uri.parse('myapp://checkout/return'),
onEvent: (event) => print(event.type),
),
);
switch (result.status) {
case CheckoutStatus.succeeded:
final paymentId = result.paymentId;
if (paymentId != null) {
await verifyPaymentOnBackend(paymentId);
}
showSuccess();
break;
case CheckoutStatus.failed:
showFailure();
break;
case CheckoutStatus.cancelled:
dismiss();
break;
case CheckoutStatus.pending:
showPending();
break;
case CheckoutStatus.expired:
showExpired();
break;
}
Return URL registration
On Android, set the callback scheme in android/app/build.gradle.kts. The package's native checkout dependency supplies the intent filter, so do not add one manually:
android {
defaultConfig {
minSdk = 23
manifestPlaceholders["dodoCallbackScheme"] = "myapp"
}
}
Remove an empty android:taskAffinity="" from MainActivity if the generated Flutter manifest contains it; it can prevent Custom Tabs from returning correctly on some devices.
On iOS, register the same scheme in ios/Runner/Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
iOS (native)
Setup
Use SFSafariViewController to open the checkout URL:
import SafariServices
let checkoutURL = URL(string: "https://checkout.dodopayments.com/...")!
let safariVC = SFSafariViewController(url: checkoutURL)
present(safariVC, animated: true)
Deep-link handling
Register your custom URL scheme in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
Handle the return in your app delegate:
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if url.scheme == "myapp" && url.host == "checkout" {
// Parse the result from the URL query parameters
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let status = components?.queryItems?.first(where: { $0.name == "status" })?.value
switch status {
case "succeeded":
let paymentId = components?.queryItems?.first(where: { $0.name == "payment_id" })?.value
verifyPaymentOnBackend(paymentId: paymentId)
case "cancelled":
dismiss()
case "expired":
showExpired()
default:
break
}
return true
}
return false
}
Android (native)
Setup
Use Chrome Custom Tabs to open the checkout URL:
import androidx.browser.customtabs.CustomTabsIntent
import android.net.Uri
val checkoutUri = Uri.parse("https://checkout.dodopayments.com/...")
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(context, checkoutUri)
Deep-link handling
Register your custom URL scheme in AndroidManifest.xml:
<activity android:name=".CheckoutReturnActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="checkout" android:path="/return" />
</intent-filter>
</activity>
Handle the return in your activity:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent.data
if (uri?.scheme == "myapp" && uri.host == "checkout") {
val status = uri.getQueryParameter("status")
val paymentId = uri.getQueryParameter("payment_id")
when (status) {
"succeeded" -> verifyPaymentOnBackend(paymentId)
"cancelled" -> dismiss()
"expired" -> showExpired()
}
}
}
Backend: Creating checkout sessions
Always create checkout sessions on your backend. Never embed your API key in the mobile app.
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment: 'test_mode',
});
const MOBILE_PRODUCTS = new Map([
['starter', 'pdt_starter123'],
['pro', 'pdt_pro456'],
]);
app.post('/api/mobile-checkout', requireAuth, async (req, res) => {
const productId = MOBILE_PRODUCTS.get(req.body.plan);
if (!productId) {
return res.status(400).json({ error: 'Invalid plan' });
}
// requireAuth derives this mapping from the authenticated server-side session.
const customerId = req.auth.dodoCustomerId;
const session = await client.checkoutSessions.create({
product_cart: [{ product_id: productId, quantity: 1 }],
customer: { customer_id: customerId },
return_url: 'myapp://checkout/return',
});
res.json({ checkout_url: session.checkout_url });
});
Verifying payment server-side
Never grant access based on the mobile SDK result alone. Always verify via webhook or API.
Via webhook
Listen for payment.succeeded webhooks. Webhook signature verification is covered in the webhook-integration skill.
app.post('/webhook', async (req, res) => {
const event = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});
if (event.type === 'payment.succeeded') {
const paymentId = event.data.payment_id;
const customerId = event.data.customer.customer_id;
// Grant access to the customer
await grantAccess(customerId);
}
res.json({ received: true });
});
Via API
Query the checkout session to confirm payment:
const session = await client.checkoutSessions.retrieve(sessionId);
if (session.payment_status === 'succeeded' && session.payment_id) {
const payment = await client.payments.retrieve(session.payment_id);
await grantAccess(payment.customer.customer_id);
}
Selling digital goods on iOS
If you're selling digital goods (software, in-app features, subscriptions) on iOS, Apple requires you to use in-app purchase APIs for certain categories. Dodo Payments can handle the payment processing, but you must comply with App Store guidelines:
- Digital content (ebooks, music, software) must use in-app purchase.
- Physical goods and services can use alternative payment methods.
- Subscriptions for digital content must use in-app purchase.
Consult Apple's App Store Review Guidelines and consider whether your product category requires in-app purchase. If it does, integrate StoreKit 2 alongside Dodo Payments for compliance.
Common mistakes
Embedding the API key in the app
Never include your API key in the app binary or client-side code. Always create checkout sessions on your backend.
// WRONG
const client = new DodoPayments({
bearerToken: 'dodo_live_abc123...', // Never hardcode
});
// CORRECT
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY, // Backend only
});
Trusting the mobile SDK result
The SDK result is informational. Always verify server-side before granting access.
// WRONG
if (result.status === 'succeeded') {
grantAccess(); // No verification
}
// CORRECT
if (result.status === 'succeeded') {
const verified = await verifyPaymentOnBackend(result.paymentId);
if (verified) {
grantAccess();
}
}
Forgetting URL scheme registration
If you don't register the custom URL scheme, the app won't receive the return callback and checkout will appear to hang.
- React Native: Use the Expo plugin or manually register in
Info.plistandAndroidManifest.xml. - Flutter: Register in both
Info.plistandAndroidManifest.xml. - iOS: Add
CFBundleURLTypestoInfo.plist. - Android: Add an intent filter with the scheme in
AndroidManifest.xml.
Not handling all result statuses
Always handle all five statuses: succeeded, failed, cancelled, pending, and expired. Each requires different UX.
// WRONG
if (result.status === 'succeeded') {
showSuccess();
}
// CORRECT
switch (result.status) {
case 'succeeded':
showSuccess();
break;
case 'failed':
showFailure();
break;
case 'cancelled':
dismiss();
break;
case 'pending':
showPending();
break;
case 'expired':
showExpired();
break;
}
Ignoring abandoned sessions
If the app crashes or is backgrounded during checkout, the session is abandoned. Always check for and recover abandoned sessions on app startup.
// WRONG
// No recovery logic
// CORRECT
const abandoned = await DodoCheckout.getAbandonedSession();
if (abandoned) {
// Reconcile and clear
await DodoCheckout.clearAbandonedSession();
}
Package names
Use @dodopayments/react-native-checkout for React Native and dodopayments_checkout for Flutter. The similarly named @dodopayments/react-native and dodo_payments_flutter packages do not exist.
Resources
- Mobile Integration
- React Native SDK
- Flutter SDK
- Selling Digital Goods on iOS
- Webhook Integration (for payment verification)
想直接用这个技能?
本站把开放许可(MIT / Apache 等)的技能按仓库打包整理到网盘,点一下转存到你自己的网盘,不用一个个从 GitHub 拉。许可未声明的技能只给原始仓库链接,不打包。
它属于哪个仓库
plugins/dodopayments/dodo-agent-plugin/skills/mobile-checkout/SKILL.md