@dodopayments/opencode-plugin 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,521 @@
1
+ ---
2
+ name: mobile-checkout
3
+ description: Guide for implementing mobile in-app checkout with Dodo Payments across React Native, Flutter, iOS, and Android platforms.
4
+ ---
5
+
6
+ # Mobile In-App Checkout
7
+
8
+ This skill covers integrating Dodo Payments hosted checkout into native and cross-platform mobile apps using secure system browser contexts.
9
+
10
+ ## When to use this skill
11
+
12
+ - Building a React Native app with Turbo Module checkout integration
13
+ - Adding checkout to a Flutter app via native bridge
14
+ - Implementing native iOS or Android checkout with secure browser contexts
15
+ - Registering custom URL schemes and deep links for payment return
16
+ - Handling abandoned checkout sessions and recovery flows
17
+ - Confirming payment authority server-side before granting access
18
+
19
+ ## Core principle: Backend creates, mobile opens
20
+
21
+ 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.
22
+
23
+ ## Architecture overview
24
+
25
+ 1. **Backend:** Create a checkout session via `client.checkoutSessions.create(...)` and return the `checkout_url` to your mobile app.
26
+ 2. **Mobile app:** Call the platform-specific SDK with the checkout URL and a registered return URL scheme.
27
+ 3. **Browser context:** The SDK opens the URL in a secure system browser: SFSafariViewController on iOS and Chrome Custom Tabs on Android.
28
+ 4. **Return:** After payment, the browser navigates to your return URL. The SDK captures the result and passes it to your app.
29
+ 5. **Verification:** Query the checkout session or listen for a webhook to confirm the payment before granting access.
30
+
31
+ ## React Native (Turbo Module)
32
+
33
+ ### Installation
34
+
35
+ ```bash
36
+ npm install @dodopayments/react-native-checkout
37
+ ```
38
+
39
+ For Expo projects, add the plugin to `app.json`:
40
+
41
+ ```json
42
+ {
43
+ "expo": {
44
+ "scheme": "myapp",
45
+ "plugins": [
46
+ [
47
+ "@dodopayments/react-native-checkout",
48
+ { "scheme": "myappcheckout" }
49
+ ]
50
+ ]
51
+ }
52
+ }
53
+ ```
54
+
55
+ The plugin registers a custom URL scheme (`myappcheckout://`) that the checkout flow uses to return to your app.
56
+
57
+ ### Setup
58
+
59
+ Register the URL listener at app startup:
60
+
61
+ ```typescript
62
+ import { Linking } from 'react-native';
63
+ import { DodoCheckout } from '@dodopayments/react-native-checkout';
64
+
65
+ // Required for iOS return-URL handling
66
+ Linking.addEventListener('url', ({ url }) => DodoCheckout.handleOpenURL(url));
67
+ ```
68
+
69
+ ### Starting checkout
70
+
71
+ ```typescript
72
+ const result = await DodoCheckout.start({
73
+ checkoutUrl: 'https://checkout.dodopayments.com/...', // from your backend
74
+ returnUrl: 'myappcheckout://checkout/return', // must match registered scheme
75
+ onEvent: (e) => console.log(e.type), // optional event logging
76
+ });
77
+
78
+ switch (result.status) {
79
+ case 'succeeded':
80
+ // Payment succeeded. Verify server-side before granting access.
81
+ await verifyPaymentOnBackend(result.paymentId);
82
+ showSuccess();
83
+ break;
84
+ case 'failed':
85
+ // Payment failed. Show error to user.
86
+ showFailure();
87
+ break;
88
+ case 'cancelled':
89
+ // User cancelled. Dismiss checkout.
90
+ dismiss();
91
+ break;
92
+ case 'pending':
93
+ // Payment is pending (e.g., awaiting 3D Secure). Show waiting state.
94
+ showPending();
95
+ break;
96
+ case 'expired':
97
+ // Checkout session expired. Prompt user to start a new checkout.
98
+ showExpired();
99
+ break;
100
+ }
101
+ ```
102
+
103
+ ### Abandoned session recovery
104
+
105
+ If the app crashes or is backgrounded during checkout, recover the session:
106
+
107
+ ```typescript
108
+ import { DodoCheckout } from '@dodopayments/react-native-checkout';
109
+
110
+ const abandoned = await DodoCheckout.getAbandonedSession();
111
+ if (abandoned) {
112
+ // Reconcile abandoned.sessionId with your backend
113
+ // Decide whether to resume or start fresh
114
+ await DodoCheckout.clearAbandonedSession();
115
+ }
116
+ ```
117
+
118
+ ### Android minSdk requirement
119
+
120
+ React Native checkout requires Android minSdk 24 or higher. Note: the general mobile documentation mentions minSdk 23, but React Native specifically requires 24.
121
+
122
+ ## Flutter
123
+
124
+ ### Installation
125
+
126
+ Add the Dodo Payments Flutter package to `pubspec.yaml`:
127
+
128
+ ```yaml
129
+ dependencies:
130
+ dodopayments_checkout: ^1.0.2
131
+ ```
132
+
133
+ ### Setup
134
+
135
+ 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:
136
+
137
+ ```dart
138
+ import 'dart:async';
139
+
140
+ import 'package:app_links/app_links.dart';
141
+ import 'package:dodopayments_checkout/dodopayments_checkout.dart';
142
+
143
+ late final StreamSubscription<Uri> checkoutLinkSubscription;
144
+
145
+ void listenForCheckoutReturns() {
146
+ checkoutLinkSubscription = AppLinks().uriLinkStream.listen((uri) {
147
+ unawaited(DodoCheckout.instance.handleOpenURL(uri.toString()));
148
+ });
149
+ }
150
+ ```
151
+
152
+ 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.
153
+
154
+ ### Starting checkout
155
+
156
+ ```dart
157
+ import 'package:dodopayments_checkout/dodopayments_checkout.dart';
158
+
159
+ final result = await DodoCheckout.instance.start(
160
+ CheckoutParams(
161
+ checkoutUrl: Uri.parse('https://checkout.dodopayments.com/...'),
162
+ returnUrl: Uri.parse('myapp://checkout/return'),
163
+ onEvent: (event) => print(event.type),
164
+ ),
165
+ );
166
+
167
+ switch (result.status) {
168
+ case CheckoutStatus.succeeded:
169
+ final paymentId = result.paymentId;
170
+ if (paymentId != null) {
171
+ await verifyPaymentOnBackend(paymentId);
172
+ }
173
+ showSuccess();
174
+ break;
175
+ case CheckoutStatus.failed:
176
+ showFailure();
177
+ break;
178
+ case CheckoutStatus.cancelled:
179
+ dismiss();
180
+ break;
181
+ case CheckoutStatus.pending:
182
+ showPending();
183
+ break;
184
+ case CheckoutStatus.expired:
185
+ showExpired();
186
+ break;
187
+ }
188
+ ```
189
+
190
+ ### Return URL registration
191
+
192
+ 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:
193
+
194
+ ```kotlin
195
+ android {
196
+ defaultConfig {
197
+ minSdk = 23
198
+ manifestPlaceholders["dodoCallbackScheme"] = "myapp"
199
+ }
200
+ }
201
+ ```
202
+
203
+ 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.
204
+
205
+ On iOS, register the same scheme in `ios/Runner/Info.plist`:
206
+
207
+ ```xml
208
+ <key>CFBundleURLTypes</key>
209
+ <array>
210
+ <dict>
211
+ <key>CFBundleURLSchemes</key>
212
+ <array>
213
+ <string>myapp</string>
214
+ </array>
215
+ </dict>
216
+ </array>
217
+ ```
218
+
219
+ ## iOS (native)
220
+
221
+ ### Setup
222
+
223
+ Use `SFSafariViewController` to open the checkout URL:
224
+
225
+ ```swift
226
+ import SafariServices
227
+
228
+ let checkoutURL = URL(string: "https://checkout.dodopayments.com/...")!
229
+ let safariVC = SFSafariViewController(url: checkoutURL)
230
+ present(safariVC, animated: true)
231
+ ```
232
+
233
+ ### Deep-link handling
234
+
235
+ Register your custom URL scheme in `Info.plist`:
236
+
237
+ ```xml
238
+ <key>CFBundleURLTypes</key>
239
+ <array>
240
+ <dict>
241
+ <key>CFBundleURLSchemes</key>
242
+ <array>
243
+ <string>myapp</string>
244
+ </array>
245
+ </dict>
246
+ </array>
247
+ ```
248
+
249
+ Handle the return in your app delegate:
250
+
251
+ ```swift
252
+ func application(
253
+ _ app: UIApplication,
254
+ open url: URL,
255
+ options: [UIApplication.OpenURLOptionsKey: Any] = [:]
256
+ ) -> Bool {
257
+ if url.scheme == "myapp" && url.host == "checkout" {
258
+ // Parse the result from the URL query parameters
259
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
260
+ let status = components?.queryItems?.first(where: { $0.name == "status" })?.value
261
+
262
+ switch status {
263
+ case "succeeded":
264
+ let paymentId = components?.queryItems?.first(where: { $0.name == "payment_id" })?.value
265
+ verifyPaymentOnBackend(paymentId: paymentId)
266
+ case "cancelled":
267
+ dismiss()
268
+ case "expired":
269
+ showExpired()
270
+ default:
271
+ break
272
+ }
273
+ return true
274
+ }
275
+ return false
276
+ }
277
+ ```
278
+
279
+ ## Android (native)
280
+
281
+ ### Setup
282
+
283
+ Use Chrome Custom Tabs to open the checkout URL:
284
+
285
+ ```kotlin
286
+ import androidx.browser.customtabs.CustomTabsIntent
287
+ import android.net.Uri
288
+
289
+ val checkoutUri = Uri.parse("https://checkout.dodopayments.com/...")
290
+ val customTabsIntent = CustomTabsIntent.Builder().build()
291
+ customTabsIntent.launchUrl(context, checkoutUri)
292
+ ```
293
+
294
+ ### Deep-link handling
295
+
296
+ Register your custom URL scheme in `AndroidManifest.xml`:
297
+
298
+ ```xml
299
+ <activity android:name=".CheckoutReturnActivity">
300
+ <intent-filter>
301
+ <action android:name="android.intent.action.VIEW" />
302
+ <category android:name="android.intent.category.DEFAULT" />
303
+ <category android:name="android.intent.category.BROWSABLE" />
304
+ <data android:scheme="myapp" android:host="checkout" android:path="/return" />
305
+ </intent-filter>
306
+ </activity>
307
+ ```
308
+
309
+ Handle the return in your activity:
310
+
311
+ ```kotlin
312
+ override fun onCreate(savedInstanceState: Bundle?) {
313
+ super.onCreate(savedInstanceState)
314
+
315
+ val uri = intent.data
316
+ if (uri?.scheme == "myapp" && uri.host == "checkout") {
317
+ val status = uri.getQueryParameter("status")
318
+ val paymentId = uri.getQueryParameter("payment_id")
319
+
320
+ when (status) {
321
+ "succeeded" -> verifyPaymentOnBackend(paymentId)
322
+ "cancelled" -> dismiss()
323
+ "expired" -> showExpired()
324
+ }
325
+ }
326
+ }
327
+ ```
328
+
329
+ ## Backend: Creating checkout sessions
330
+
331
+ Always create checkout sessions on your backend. Never embed your API key in the mobile app.
332
+
333
+ ```typescript
334
+ import DodoPayments from 'dodopayments';
335
+
336
+ const client = new DodoPayments({
337
+ bearerToken: process.env.DODO_PAYMENTS_API_KEY,
338
+ environment: 'test_mode',
339
+ });
340
+
341
+ const MOBILE_PRODUCTS = new Map([
342
+ ['starter', 'pdt_starter123'],
343
+ ['pro', 'pdt_pro456'],
344
+ ]);
345
+
346
+ app.post('/api/mobile-checkout', requireAuth, async (req, res) => {
347
+ const productId = MOBILE_PRODUCTS.get(req.body.plan);
348
+
349
+ if (!productId) {
350
+ return res.status(400).json({ error: 'Invalid plan' });
351
+ }
352
+
353
+ // requireAuth derives this mapping from the authenticated server-side session.
354
+ const customerId = req.auth.dodoCustomerId;
355
+
356
+ const session = await client.checkoutSessions.create({
357
+ product_cart: [{ product_id: productId, quantity: 1 }],
358
+ customer: { customer_id: customerId },
359
+ return_url: 'myapp://checkout/return',
360
+ });
361
+
362
+ res.json({ checkout_url: session.checkout_url });
363
+ });
364
+ ```
365
+
366
+ ## Verifying payment server-side
367
+
368
+ Never grant access based on the mobile SDK result alone. Always verify via webhook or API.
369
+
370
+ ### Via webhook
371
+
372
+ Listen for `payment.succeeded` webhooks. Webhook signature verification is covered in the `webhook-integration` skill.
373
+
374
+ ```typescript
375
+ app.post('/webhook', async (req, res) => {
376
+ const event = client.webhooks.unwrap(req.body.toString(), {
377
+ headers: {
378
+ 'webhook-id': req.headers['webhook-id'] as string,
379
+ 'webhook-signature': req.headers['webhook-signature'] as string,
380
+ 'webhook-timestamp': req.headers['webhook-timestamp'] as string,
381
+ },
382
+ });
383
+
384
+ if (event.type === 'payment.succeeded') {
385
+ const paymentId = event.data.payment_id;
386
+ const customerId = event.data.customer.customer_id;
387
+
388
+ // Grant access to the customer
389
+ await grantAccess(customerId);
390
+ }
391
+
392
+ res.json({ received: true });
393
+ });
394
+ ```
395
+
396
+ ### Via API
397
+
398
+ Query the checkout session to confirm payment:
399
+
400
+ ```typescript
401
+ const session = await client.checkoutSessions.retrieve(sessionId);
402
+
403
+ if (session.payment_status === 'succeeded' && session.payment_id) {
404
+ const payment = await client.payments.retrieve(session.payment_id);
405
+ await grantAccess(payment.customer.customer_id);
406
+ }
407
+ ```
408
+
409
+ ## Selling digital goods on iOS
410
+
411
+ 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:
412
+
413
+ - Digital content (ebooks, music, software) must use in-app purchase.
414
+ - Physical goods and services can use alternative payment methods.
415
+ - Subscriptions for digital content must use in-app purchase.
416
+
417
+ 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.
418
+
419
+ ## Common mistakes
420
+
421
+ ### Embedding the API key in the app
422
+
423
+ Never include your API key in the app binary or client-side code. Always create checkout sessions on your backend.
424
+
425
+ ```typescript
426
+ // WRONG
427
+ const client = new DodoPayments({
428
+ bearerToken: 'dodo_live_abc123...', // Never hardcode
429
+ });
430
+
431
+ // CORRECT
432
+ const client = new DodoPayments({
433
+ bearerToken: process.env.DODO_PAYMENTS_API_KEY, // Backend only
434
+ });
435
+ ```
436
+
437
+ ### Trusting the mobile SDK result
438
+
439
+ The SDK result is informational. Always verify server-side before granting access.
440
+
441
+ ```typescript
442
+ // WRONG
443
+ if (result.status === 'succeeded') {
444
+ grantAccess(); // No verification
445
+ }
446
+
447
+ // CORRECT
448
+ if (result.status === 'succeeded') {
449
+ const verified = await verifyPaymentOnBackend(result.paymentId);
450
+ if (verified) {
451
+ grantAccess();
452
+ }
453
+ }
454
+ ```
455
+
456
+ ### Forgetting URL scheme registration
457
+
458
+ If you don't register the custom URL scheme, the app won't receive the return callback and checkout will appear to hang.
459
+
460
+ - React Native: Use the Expo plugin or manually register in `Info.plist` and `AndroidManifest.xml`.
461
+ - Flutter: Register in both `Info.plist` and `AndroidManifest.xml`.
462
+ - iOS: Add `CFBundleURLTypes` to `Info.plist`.
463
+ - Android: Add an intent filter with the scheme in `AndroidManifest.xml`.
464
+
465
+ ### Not handling all result statuses
466
+
467
+ Always handle all five statuses: `succeeded`, `failed`, `cancelled`, `pending`, and `expired`. Each requires different UX.
468
+
469
+ ```typescript
470
+ // WRONG
471
+ if (result.status === 'succeeded') {
472
+ showSuccess();
473
+ }
474
+
475
+ // CORRECT
476
+ switch (result.status) {
477
+ case 'succeeded':
478
+ showSuccess();
479
+ break;
480
+ case 'failed':
481
+ showFailure();
482
+ break;
483
+ case 'cancelled':
484
+ dismiss();
485
+ break;
486
+ case 'pending':
487
+ showPending();
488
+ break;
489
+ case 'expired':
490
+ showExpired();
491
+ break;
492
+ }
493
+ ```
494
+
495
+ ### Ignoring abandoned sessions
496
+
497
+ If the app crashes or is backgrounded during checkout, the session is abandoned. Always check for and recover abandoned sessions on app startup.
498
+
499
+ ```typescript
500
+ // WRONG
501
+ // No recovery logic
502
+
503
+ // CORRECT
504
+ const abandoned = await DodoCheckout.getAbandonedSession();
505
+ if (abandoned) {
506
+ // Reconcile and clear
507
+ await DodoCheckout.clearAbandonedSession();
508
+ }
509
+ ```
510
+
511
+ ## Package names
512
+
513
+ 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.
514
+
515
+ ## Resources
516
+
517
+ - [Mobile Integration](https://docs.dodopayments.com/developer-resources/mobile-integration)
518
+ - [React Native SDK](https://docs.dodopayments.com/developer-resources/sdks/react-native)
519
+ - [Flutter SDK](https://pub.dev/packages/dodopayments_checkout)
520
+ - [Selling Digital Goods on iOS](https://docs.dodopayments.com/features/appstore-digital-goods)
521
+ - [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks/intents) (for payment verification)