Dodo Payments Checkout Flutter SDK
Open Dodo Payments' hosted checkout in a system browser tab
(SFSafariViewController on iOS, a Custom Tab on Android) and get a clean
result from one call.
This package is a thin wrapper: all checkout logic lives in the native
iOS (DodoCheckout, Swift) and Android (com.dodopayments.api:checkout-android)
cores. The Dart layer passes the call through a typed Pigeon channel and
maps the result back.
Install
dependencies:
dodopayments_checkout: ^1.0.0
Requirements: Flutter 3.44+ / Dart 3.12+, iOS 16+, Android minSdk 23.
Setup
Register a callback URL scheme so the OS routes the checkout return back to your app:
-
iOS: add a URL type for your scheme in
Info.plist, and forward incoming URLs (e.g. viaapp_links) intoDodoCheckout.instance.handleOpenURL(url)—SFSafariViewControllerhas no in-process way to catch its own return URL. -
Android: set your callback scheme as a Gradle manifest placeholder — the underlying Android core's own manifest already declares the redirect activity's intent-filter, so this one property is the entire setup cost:
android { defaultConfig { manifestPlaceholders["dodoCallbackScheme"] = "myapp" } }If your
MainActivitysetsandroid:taskAffinity=""(the stockflutter createtemplate default, meant to stop the launcher icon from resurrecting a stale engine instance), remove it or give the SDK's activities the same explicit affinity. The SDK's own activities inherit the app's default task affinity, and leavingMainActivityon a different one is a real mismatch — some OEM Android skins (observed on Vivo) can then treat a long-lived Custom Tab's return as belonging to a different task and recreate the SDK's host activity from scratch, losing the in-flight checkout and surfacingPLATFORM_ERROR: Checkout was launched without its parameters.
Use
import 'package:dodopayments_checkout/dodopayments_checkout.dart';
final result = await DodoCheckout.instance.start(
CheckoutParams(
checkoutUrl: Uri.parse(checkoutUrl), // from your backend's POST /checkouts
returnUrl: Uri.parse('myapp://checkout/return'), // scheme must be registered (see Setup)
onEvent: (event) => print(event.type), // logging only — never decide outcome from events
),
);
switch (result.status) {
case CheckoutStatus.succeeded: showSuccess(result.paymentId); // UI only — confirm server-side
case CheckoutStatus.failed: showFailure();
case CheckoutStatus.cancelled: await reconcileAbandonedSession(); // outcome unknown — NOT a failure
case CheckoutStatus.pending: await reconcileAbandonedSession(); // may be unparsed, not just async
case CheckoutStatus.expired: showExpired();
}
Your backend creates the checkout session (with your secret key) and sends the
checkout_url to the app. Set the session's return_url to the same URL you
pass as returnUrl — it never has to resolve, because the SDK cancels the
navigation before it loads.
What the result means
The result comes from the return_url query string. It is a UI hint, not
proof of payment. This SDK never calls the Dodo API and holds no API key.
Grant access on your backend from the webhook (payment.succeeded /
subscription.active) or by retrieving the payment with your secret key.
result.raw carries every query parameter verbatim.
Verify the payment
Confirm every payment from your backend, not from the mobile result:
- Webhook: Dodo Payments calls your backend when a payment succeeds or a subscription activates. Check the Webhooks guide.
- Verification API: look up
paymentIdwith your secret key via Get Payment Detail.
cancelled is not a failure
CheckoutStatus.cancelled means the user dismissed the browser before any
return URL arrived, so the SDK never learned the outcome. The payment may
have gone through. A user who pays and then taps ✕ while the hosted "Payment
Successful" page counts down its redirect produces cancelled, and is
indistinguishable, from the SDK's side, from a user who closed the browser
without paying.
Showing "Payment failed" here tells a paying customer their money vanished. Resolve it instead: the SDK keeps the session on record for exactly this case.
Abandoned sessions
A session stays on record whenever the SDK never saw a return URL it could
resolve to a durable outcome — the app was killed mid-checkout, start
completed with CheckoutStatus.cancelled, or it completed with
CheckoutStatus.pending from an unparseable return URL rather than a genuinely
async payment method. Reconcile it server-side, both on next launch and right
after a cancelled or pending result:
import 'package:dodopayments_checkout/dodopayments_checkout.dart';
Future<void> reconcileAbandonedSession() async {
final abandoned = await DodoCheckout.instance.getAbandonedSession();
if (abandoned == null) {
dismiss(); // nothing in flight
return;
}
// Ask *your* backend what happened to abandoned.sessionId — it has the
// webhook (`payment.succeeded`) or can call Get Payment Detail with your
// secret key. Show a spinner while you wait; an async method may still be
// settling, so treat "no record yet" as pending, not failed — and only
// clear the record once you have a terminal outcome, or a later retry
// has nothing left to reconcile against if this one comes back.
final outcome = await myBackend.outcomeForSession(abandoned.sessionId);
if (outcome.isTerminal) {
await DodoCheckout.instance.clearAbandonedSession();
}
show(outcome);
}
Customization
The checkout browser's toolbar, buttons, and color scheme can be customized via customization on CheckoutParams. See the Appearance Customization docs for all available options.
Errors
start throws CheckoutException only for misuse or platform failure. The
code is a CheckoutErrorCode:
| Code | Native code | Meaning |
|---|---|---|
invalidCheckoutUrl |
INVALID_CHECKOUT_URL |
Not a checkout.dodopayments.com / test.checkout.dodopayments.com session URL |
invalidReturnUrl |
INVALID_RETURN_URL |
Not a valid absolute URL |
alreadyInProgress |
ALREADY_IN_PROGRESS |
A checkout is already running (only one at a time) |
platformError |
PLATFORM_ERROR |
Unexpected platform failure |
A user cancelling or a declined payment is a result
(CheckoutStatus.cancelled / CheckoutStatus.failed), never an exception.
Example
example/lib/main.dart demonstrates a full price-page flow. Generate its
platform folders once with flutter create --platforms=android,ios . inside
example/ and run it.
Development notes
- Platform channels are generated by Pigeon from
pigeons/messages.dart. After editing that file:dart run pigeon --input pigeons/messages.dart. - iOS bundles the Swift core as source (see
ios/dodopayments_checkout.podspec); Android depends on thecom.dodopayments.api:checkout-androidMaven artifact (seeandroid/build.gradlefor local-dev pointers to the sibling cores in this monorepo).
Libraries
- dodopayments_checkout
- Dodo Payments mobile checkout for Flutter.