Sooner or later a Flutter app needs a capability that only ships as a native SDK. For me it was payments: I integrated Samsung Pay into a large retail e-commerce app where the wallet SDK exists for Android and iOS, with no Dart package and no way around it. The integration went through a Flutter platform channel to the native SDK, and it taught me that the channel itself is the easy part. What decides whether the codebase stays healthy is everything you build around it.
This post is that pattern. All examples use an invented VendorPay SDK and generic code, not any vendor's real API.
When a platform channel is the right tool
Reach for a channel only after three cheaper options fail:
- A maintained plugin already exists on pub.dev. Use it; a channel you own is a liability you own.
- The library is C or C++. Use
dart:ffiand call it directly, no channel involved. - The flow is really a web flow. A redirect or WebView costs less than two native integrations.
The case that remains: the vendor ships Kotlin or Java for Android and Swift or Objective-C for iOS, the capability is core to your product, and nobody has wrapped it properly. Payment wallets live squarely in that case.
Hide the channel behind a gateway
The most important line of the whole integration is an abstract class. App code should never know a channel exists:
abstract class PaymentGateway {
Future<bool> isAvailable();
Future<PaymentResult> pay(PaymentRequest request);
}
Two implementations exist. The real one talks to the channel. The fake one answers from configuration and runs everywhere: widget tests, integration tests, previews on a simulator without the vendor's hardware requirements. Dependency injection decides which one the app gets, the same way a data layer swaps a remote API for a local cache.

This is the same discipline as enforced boundaries in a monorepo: the structure only helps if the boundary is real. One class owns the channel. Everything else depends on the interface.
A disciplined MethodChannel
The Dart side of the real implementation is small and boring on purpose:
class VendorPayGateway implements PaymentGateway {
static const _channel = MethodChannel('app/vendor_pay');
@override
Future<PaymentResult> pay(PaymentRequest request) async {
try {
final reply = await _channel.invokeMapMethod<String, Object?>(
'pay',
request.toMap(),
);
return PaymentResult.fromMap(reply!);
} on PlatformException catch (e) {
throw PaymentException.from(e.code, e.message);
}
}
}
Three rules keep it boring:
- Method names and payload keys live in one file per side, not scattered as string literals.
- Payloads are maps with named keys, never positional lists. Maps survive versioning; positions do not.
- Every payload carries a
versionkey so a newer app can talk to an older native handler during staged rollouts.
On Android, the handler's job is to translate, not to think:
class VendorPayHandler(
private val sdk: VendorPaySdk
) : MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"pay" -> sdk.startPayment(
call.argument<Map<String, Any?>>("request"),
OneShotResult(result)
)
else -> result.notImplemented()
}
}
}
OneShotResult is a ten-line wrapper that forwards the first success or error and ignores the rest. It exists because vendor SDK callbacks do not read the Flutter documentation: they can fire twice, fire after the Activity detached, or fire on a callback thread. Replying twice crashes the engine bridge, so the guard is not optional. The Swift side mirrors the same contract with a FlutterMethodChannel handler.
One threading fact does most of the work here: method calls arrive on the platform's main thread on both Android and iOS. Kick the SDK's heavy work onto its own queue, then hop back to the main thread to reply.

The error contract is the API
PlatformException.code is a wire format, not a vocabulary your app should speak. At the boundary, every native failure maps into a small set of domain errors, and the set is closed:
enum PaymentError {
cancelled, // the user backed out; not an error dialog
notSupported, // device or region cannot use this method
declined, // the transaction was refused
unavailable, // SDK missing or not ready; offer another method
unknown, // everything else, logged with its native code
}
The UI switches on these five. Nothing upstream parses vendor strings, and when the vendor renames an error in their next release, exactly one file changes. The reverse rule also holds: the native handler never invents its own codes; it reports what the SDK said and lets the Dart boundary classify it.
Payments raise the stakes
A payment SDK makes the boundary discipline non-negotiable, for reasons that apply to any sensitive integration:
- Nothing personal crosses into logs. Channel payloads carry payment context, so they are excluded from analytics breadcrumbs and crash reports on both sides. PCI DSS alignment starts with what you refuse to log.
- Integrity comes first. The flow opens only after device integrity passes, Play Integrity on Android and App Attest on iOS. A channel is an extra door into native code; check who is knocking before opening the vault behind it.
- Absence is a state, not an error. On devices without the wallet,
isAvailable()returns false and the UI simply offers card entry instead. An integration that treats unavailability as a crash path punishes users for their hardware.
Testing without a device
The gateway split decides the testing story:
- App and widget tests use the fake gateway. They know nothing about channels, so they run anywhere, fast.
- The channel implementation gets its own tests with a mock message handler, asserting the method name and payload and replying with canned maps or a
PlatformException:
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'pay');
return {'status': 'declined'};
});
- The native halves are covered by a small set of real-device smoke journeys in the nightly suite, because a codec mismatch or a lifecycle bug only shows up there. That cadence, fast fakes on every commit and real devices at night, is the same split I use for the whole test practice.
The costs, and when they are worth paying
A channel means every change touches three codebases, every payload needs codec discipline, and every reviewer needs to hold two platforms in their head. That overhead is real, so spend it only where the capability is the product. Payments cleared that bar easily: the alternative was not shipping the feature at all.
Held to these rules, the integration stays a quiet corner of the codebase: one interface, one channel class per platform, five error codes, and a fake that makes the rest of the app forget native code exists. The payment flows this pattern carried are live today in Sharjah Coop, serving a government retail platform on both stores.
