Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Guide for implementing mobile in-app checkout with Dodo Payments across React Native, Flutter, iOS, and Android platforms.
.claude/skills/hashgraph-online-mobile-checkout/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 37% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 61% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 69% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 150% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 101% | 0% |
This skill covers integrating Dodo Payments hosted checkout into native and cross-platform mobile apps using secure system browser contexts.
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.
client.checkoutSessions.create(...) and return the checkout_url to your mobile app.bashnpm install @dodopayments/react-native-checkout
For Expo projects, add the plugin to app.json:
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.
Register the URL listener at app startup:
typescriptimport { Linking } from 'react-native'; import { DodoCheckout } from '@dodopayments/react-native-checkout'; // Required for iOS return-URL handling Linking.addEventListener('url', ({ url }) => DodoCheckout.handleOpenURL(url));
typescriptconst 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; }
If the app crashes or is backgrounded during checkout, recover the session:
typescriptimport { 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(); }
React Native checkout requires Android minSdk 24 or higher. Note: the general mobile documentation mentions minSdk 23, but React Native specifically requires 24.
Add the Dodo Payments Flutter package to pubspec.yaml:
yamldependencies: dodopayments_checkout: ^1.0.2
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:
dartimport '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.
dartimport '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; }
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:
kotlinandroid { 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:
xml<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLSchemes</key> <array> <string>myapp</string> </array> </dict> </array>
Use SFSafariViewController to open the checkout URL:
swiftimport SafariServices let checkoutURL = URL(string: "https://checkout.dodopayments.com/...")! let safariVC = SFSafariViewController(url: checkoutURL) present(safariVC, animated: true)
Register your custom URL scheme in Info.plist:
xml<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLSchemes</key> <array> <string>myapp</string> </array> </dict> </array>
Handle the return in your app delegate:
swiftfunc 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 }
Use Chrome Custom Tabs to open the checkout URL:
kotlinimport 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)
Register your custom URL scheme in AndroidManifest.xml:
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:
kotlinoverride 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() } } }
Always create checkout sessions on your backend. Never embed your API key in the mobile app.
typescriptimport 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 }); });
Never grant access based on the mobile SDK result alone. Always verify via webhook or API.
Listen for payment.succeeded webhooks. Webhook signature verification is covered in the webhook-integration skill.
typescriptapp.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 }); });
Query the checkout session to confirm payment:
typescriptconst 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); }
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:
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.
Never include your API key in the app binary or client-side code. Always create checkout sessions on your backend.
typescript// 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 });
The SDK result is informational. Always verify server-side before granting access.
typescript// WRONG if (result.status === 'succeeded') { grantAccess(); // No verification } // CORRECT if (result.status === 'succeeded') { const verified = await verifyPaymentOnBackend(result.paymentId); if (verified) { grantAccess(); } }
If you don't register the custom URL scheme, the app won't receive the return callback and checkout will appear to hang.
Info.plist and AndroidManifest.xml.Info.plist and AndroidManifest.xml.CFBundleURLTypes to Info.plist.AndroidManifest.xml.Always handle all five statuses: succeeded, failed, cancelled, pending, and expired. Each requires different UX.
typescript// 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; }
If the app crashes or is backgrounded during checkout, the session is abandoned. Always check for and recover abandoned sessions on app startup.
typescript// WRONG // No recovery logic // CORRECT const abandoned = await DodoCheckout.getAbandonedSession(); if (abandoned) { // Reconcile and clear await DodoCheckout.clearAbandonedSession(); }
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.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 30,812 | 19,777 | -36% | 1 | 1 | 0% | 5,075 | 6,962 | +37% | 0 | 0 | — |
case-02 | fail→pass | 39,172 | 22,347 | -43% | 1 | 1 | 0% | 5,601 | 8,996 | +61% | 0 | 0 | — |
case-03 | fail→pass | 25,255 | 18,540 | -27% | 1 | 1 | 0% | 4,139 | 7,002 | +69% | 0 | 0 | — |
case-04 | fail→fail | 19,779 | 14,612 | -26% | 1 | 1 | 0% | 3,600 | 6,540 | +82% | 0 | 0 | — |
case-05 | fail→fail | 25,646 | 23,159 | -10% | 1 | 1 | 0% | 4,019 | 7,572 | +88% | 0 | 0 | — |
case-11 | fail→pass | 19,848 | 15,915 | -20% | 1 | 1 | 0% | 2,331 | 5,818 | +150% | 0 | 0 | — |
case-06 | fail→fail | 24,202 | 24,558 | +1% | 1 | 1 | 0% | 3,417 | 7,733 | +126% | 0 | 0 | — |
case-07 | fail→pass | 14,276 | 6,175 | -57% | 1 | 1 | 0% | 2,534 | 5,086 | +101% | 0 | 0 | — |
case-08 | fail→pass | 12,439 | 4,018 | -68% | 1 | 1 | 0% | 1,263 | 4,574 | +262% | 0 | 0 | — |
case-09 | fail→pass | 14,122 | 14,239 | +1% | 1 | 1 | 0% | 1,550 | 5,269 | +240% | 0 | 0 | — |
case-10 | fail→pass | 23,104 | 6,590 | -71% | 1 | 1 | 0% | 2,940 | 5,087 | +73% | 0 | 0 | — |
case-12 | fail→pass | 15,038 | 7,739 | -49% | 1 | 1 | 0% | 1,846 | 4,363 | +136% | 0 | 0 | — |
case-13 | fail→pass | 15,962 | 6,951 | -56% | 1 | 1 | 0% | 2,062 | 5,346 | +159% | 0 | 0 | — |
case-14 | fail→pass | 17,767 | 4,326 | -76% | 1 | 1 | 0% | 2,348 | 4,755 | +103% | 0 | 0 | — |
case-15 | fail→pass | 20,502 | 11,518 | -44% | 1 | 1 | 0% | 2,668 | 4,814 | +80% | 0 | 0 | — |
case-16 | pass→pass | 15,853 | 4,546 | -71% | 1 | 1 | 0% | 1,723 | 4,702 | +173% | 0 | 0 | — |
case-17 | fail→pass | 13,248 | 11,422 | -14% | 1 | 1 | 0% | 2,482 | 5,715 | +130% | 0 | 0 | — |
case-18 | pass→pass | 17,089 | 13,108 | -23% | 1 | 1 | 0% | 2,397 | 5,528 | +131% | 0 | 0 | — |
case-19 | fail→pass | 13,224 | 8,741 | -34% | 1 | 1 | 0% | 2,576 | 5,653 | +119% | 0 | 0 | — |
case-20 | pass→pass | 19,929 | 10,677 | -46% | 1 | 1 | 0% | 2,557 | 5,924 | +132% | 0 | 0 | — |
case-21 | fail→pass | 24,603 | 18,435 | -25% | 1 | 1 | 0% | 3,307 | 5,939 | +80% | 0 | 0 | — |
case-22 | fail→pass | 25,396 | 14,225 | -44% | 1 | 1 | 0% | 2,765 | 6,154 | +123% | 0 | 0 | — |
case-23 | pass→pass | 13,441 | 12,819 | -5% | 1 | 1 | 0% | 2,203 | 6,329 | +187% | 0 | 0 | — |
case-24 | fail→pass | 17,525 | 10,214 | -42% | 1 | 1 | 0% | 1,744 | 5,274 | +202% | 0 | 0 | — |
case-25 | fail→fail | 6,028 | 8,464 | +40% | 1 | 1 | 0% | 1,090 | 4,367 | +301% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 25 cases were attempted. The headline lift of +68 percentage points is the difference between those two pass rates over the 25 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.