Implementing eSIM in Flutter: A Complete Technical Guide
The eSIM Profile Format
Before writing a single line of code, you need to understand what you are actually installing on the device. Every eSIM profile is identified by an activation string in the LPA (Local Profile Assistant) format:
LPA:1$smdp.example.com$MATCHING_ID
This string has three components separated by $:
- LPA:1 — The protocol identifier. The
1indicates the consumer RSP (Remote SIM Provisioning) protocol defined by GSMA SGP.22. - SM-DP+ address — The hostname of the Subscription Manager Data Preparation server. This is the server that stores the encrypted profile and handles mutual authentication with the device’s eUICC.
- Matching ID — A unique token that identifies the specific profile assigned to this user. The SM-DP+ uses this to look up and release the correct profile during the download handshake.
When a user purchases a plan through Rivio, our backend generates this activation string by calling the SM-DP+ API’s downloadOrder endpoint. The string is then delivered to the Flutter app, which passes it to the native platform layer for installation.
Android: Three Approaches to eSIM Installation
Android provides three distinct methods for installing eSIM profiles, each with different privilege requirements and user experience trade-offs.
Approach 1: LPA Intent (Lowest Barrier)
The simplest approach launches the device’s built-in LPA (Local Profile Assistant) app. This requires no special permissions but gives you zero control over the user experience.
// Kotlin — Launch system LPA with activation code
fun launchLpaIntent(context: Context, activationCode: String) {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(activationCode) // LPA:1$smdp.example.com$MATCHING_ID
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
The system LPA handles everything: eUICC detection, SM-DP+ authentication, profile download, and user confirmation. The problem? You have no callback. You do not know if the installation succeeded, failed, or if the user simply backed out. For a production app, this is insufficient.
Approach 2: ACTION_VIEW Intent with Carrier App
Some OEMs register custom intent handlers for the LPA:1$ URI scheme. Samsung, for instance, routes these through their own SIM manager. The behavior is identical to Approach 1 in terms of developer control: you fire and forget.
Approach 3: EuiccManager (Production Choice)
The EuiccManager API, introduced in Android 9 (API 28), gives you full programmatic control over profile lifecycle. This is what you want for a production app.
// Kotlin — Full EuiccManager implementation
class EsimInstaller(private val context: Context) {
private val euiccManager = context.getSystemService(Context.EUICC_SERVICE) as EuiccManager
fun isEsimSupported(): Boolean {
return euiccManager.isEnabled
}
fun isDeviceCarrierLocked(): Boolean {
// Check if eUICC only accepts profiles from a specific carrier
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
return tm.isNetworkRoaming && !euiccManager.isEnabled
}
fun downloadProfile(
activationCode: String,
onResult: (success: Boolean, errorCode: Int?) -> Unit
) {
if (!euiccManager.isEnabled) {
onResult(false, EUICC_NOT_AVAILABLE)
return
}
val sub = DownloadableSubscription
.forActivationCode(activationCode)
val callbackIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE_DOWNLOAD,
Intent(ACTION_DOWNLOAD_RESULT).setPackage(context.packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
// Register receiver for the result
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val resultCode = getResultCode()
when (resultCode) {
EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_OK -> {
onResult(true, null)
}
EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_RESOLVABLE_ERROR -> {
// User confirmation needed — launch resolution activity
val resolutionIntent = intent.getParcelableExtra<PendingIntent>(
EuiccManager.EXTRA_EMBEDDED_SUBSCRIPTION_RESOLUTION_INTENT
)
// Handle resolution...
onResult(false, RESULT_NEEDS_USER_CONFIRMATION)
}
EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_ERROR -> {
val detailedCode = intent.getIntExtra(
EuiccManager.EXTRA_EMBEDDED_SUBSCRIPTION_DETAILED_CODE, -1
)
onResult(false, detailedCode)
}
}
context.unregisterReceiver(this)
}
}
context.registerReceiver(
receiver,
IntentFilter(ACTION_DOWNLOAD_RESULT),
Context.RECEIVER_EXPORTED
)
euiccManager.downloadSubscription(sub, true, callbackIntent)
}
companion object {
const val ACTION_DOWNLOAD_RESULT = "com.rivio.app.DOWNLOAD_RESULT"
const val REQUEST_CODE_DOWNLOAD = 1001
const val EUICC_NOT_AVAILABLE = -100
const val RESULT_NEEDS_USER_CONFIRMATION = -200
}
}
Carrier Privileges: The Critical Piece
EuiccManager.downloadSubscription() checks whether your app has carrier privileges before allowing a silent installation. Without carrier privileges, Android falls back to a system confirmation dialog — or outright rejects the call on some OEM implementations.
Carrier privileges are established through UICC Access Rules (ARA-M), which are records stored inside the eSIM profile itself. The rule contains:
- Your app’s package name (e.g.,
com.rivio.app) - The SHA-256 hash of your app’s signing certificate
When the profile is downloaded onto the eUICC, the system reads these ARA-M records and grants your app the android.permission.WRITE_EMBEDDED_SUBSCRIPTIONS privilege implicitly. This means you must coordinate with your SM-DP+ provider to embed your certificate hash in every profile they generate for your platform.
To get your signing certificate hash:
# Get SHA-256 of your release signing key
keytool -list -v -keystore your-release.keystore -alias your-alias \
| grep SHA256 | awk '{print $2}' | tr -d ':'
iOS: Universal Links vs. CTCellularPlanProvisioning
Universal Link Approach (No Entitlement Required)
The simplest iOS path uses a Universal Link that opens the native eSIM installation flow. This works without any special entitlements but, like Android’s LPA intent, offers minimal control.
// Swift — Open eSIM installation via Settings URL
import UIKit
func openEsimInstallation(smdpAddress: String, matchingId: String) {
// Construct the cellular plan URL
var components = URLComponents()
components.scheme = "https"
components.host = "esimsetup.apple.com"
components.path = "/esim_download_ota"
components.queryItems = [
URLQueryItem(name: "carddata", value: "LPA:1$\(smdpAddress)$\(matchingId)")
]
guard let url = components.url else { return }
UIApplication.shared.open(url, options: [:]) { success in
if !success {
// Fallback: device may not support eSIM
print("Failed to open eSIM setup URL")
}
}
}
This opens the native iOS eSIM installation sheet. The user taps “Continue,” the profile downloads, and iOS handles everything. You get a boolean indicating whether the URL was opened, but not whether the installation completed.
CTCellularPlanProvisioning (Production Choice)
For full programmatic control, Apple provides CTCellularPlanProvisioning, part of the CoreTelephony framework. This API lets you install eSIM profiles silently and receive detailed status callbacks.
// Swift — CTCellularPlanProvisioning implementation
import CoreTelephony
class EsimInstaller {
private let provisioning = CTCellularPlanProvisioning()
func supportsEsim() -> Bool {
return provisioning.supportsCellularPlan()
}
func installProfile(
smdpAddress: String,
matchingId: String,
iccid: String,
completion: @escaping (Result<Void, EsimError>) -> Void
) {
guard provisioning.supportsCellularPlan() else {
completion(.failure(.euiccNotAvailable))
return
}
let plan = CTCellularPlanProvisioningRequest()
plan.address = smdpAddress
plan.matchingID = matchingId
plan.iccid = iccid
// Optional: OID for confirmation code
// plan.confirmationCode = "..."
provisioning.addPlan(with: plan) { result in
DispatchQueue.main.async {
switch result {
case .unknown:
completion(.failure(.unknown))
case .fail:
completion(.failure(.installationFailed))
case .success:
completion(.success(()))
@unknown default:
completion(.failure(.unknown))
}
}
}
}
}
enum EsimError: Error {
case euiccNotAvailable
case installationFailed
case profileAlreadyInstalled
case carrierLocked
case unknown
}
Apple eSIM Entitlement Process
Here is the reality: CTCellularPlanProvisioning.addPlan() is gated behind the com.apple.developer.carrier-account entitlement. You cannot simply toggle this in Xcode. The process:
- Contact Apple. Reach out to your Apple Sales Representative or use the Apple MFi Program contact channels if you are an existing partner.
- Submit your business case. Apple needs to understand your SM-DP+ infrastructure, your carrier agreements, and your intended user experience.
- Technical review. Apple’s team evaluates your integration, usually requiring a TestFlight build demonstrating the flow.
- Entitlement grant. Once approved, the entitlement is added to your App ID in your Apple Developer account.
- Timeline. In our experience at Rivio, plan for 8-12 weeks from initial contact to entitlement approval. Build time for this in your project schedule.
Without this entitlement, addPlan() will silently fail or return .fail. The Universal Link approach remains your fallback for users until the entitlement is secured.
Flutter Bridge: MethodChannel Implementation
Now we connect the native layers to Flutter using a MethodChannel. This is the architecture that powers Rivio’s production app.
// Dart — eSIM Platform Channel Service
import 'package:flutter/services.dart';
class EsimService {
static const _channel = MethodChannel('com.rivio.app/esim');
/// Check if device supports eSIM
Future<bool> isEsimSupported() async {
try {
final result = await _channel.invokeMethod<bool>('isEsimSupported');
return result ?? false;
} on PlatformException catch (e) {
_logError('isEsimSupported', e);
return false;
}
}
/// Check if device eUICC is carrier-locked
Future<bool> isCarrierLocked() async {
try {
final result = await _channel.invokeMethod<bool>('isCarrierLocked');
return result ?? false;
} on PlatformException {
return false;
}
}
/// Install eSIM profile
Future<EsimInstallResult> installProfile({
required String activationCode,
required String iccid,
}) async {
try {
final result = await _channel.invokeMethod<Map>(
'installProfile',
{
'activationCode': activationCode,
'iccid': iccid,
},
);
if (result == null) return EsimInstallResult.unknown;
return EsimInstallResult.fromMap(Map<String, dynamic>.from(result));
} on PlatformException catch (e) {
return EsimInstallResult(
success: false,
errorCode: e.code,
errorMessage: e.message,
);
}
}
/// Get list of installed eSIM profile ICCIDs
Future<List<String>> getInstalledProfiles() async {
try {
final result = await _channel.invokeMethod<List>('getInstalledProfiles');
return result?.cast<String>() ?? [];
} on PlatformException {
return [];
}
}
void _logError(String method, PlatformException e) {
// Send to your analytics/crash reporting
debugPrint('EsimService.$method failed: ${e.code} - ${e.message}');
}
}
class EsimInstallResult {
final bool success;
final String? errorCode;
final String? errorMessage;
final bool needsUserConfirmation;
const EsimInstallResult({
required this.success,
this.errorCode,
this.errorMessage,
this.needsUserConfirmation = false,
});
static const unknown = EsimInstallResult(
success: false,
errorCode: 'UNKNOWN',
);
factory EsimInstallResult.fromMap(Map<String, dynamic> map) {
return EsimInstallResult(
success: map['success'] as bool? ?? false,
errorCode: map['errorCode'] as String?,
errorMessage: map['errorMessage'] as String?,
needsUserConfirmation: map['needsUserConfirmation'] as bool? ?? false,
);
}
}
Android MethodChannel Handler
// Kotlin — Flutter MethodChannel handler
class EsimMethodChannel(
private val context: Context,
flutterEngine: FlutterEngine
) : MethodChannel.MethodCallHandler {
private val channel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.rivio.app/esim"
)
private val installer = EsimInstaller(context)
init {
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isEsimSupported" -> {
result.success(installer.isEsimSupported())
}
"isCarrierLocked" -> {
result.success(installer.isDeviceCarrierLocked())
}
"installProfile" -> {
val activationCode = call.argument<String>("activationCode")
?: return result.error("MISSING_ARG", "activationCode required", null)
installer.downloadProfile(activationCode) { success, errorCode ->
val response = mapOf(
"success" to success,
"errorCode" to errorCode?.toString(),
"needsUserConfirmation" to (errorCode == EsimInstaller.RESULT_NEEDS_USER_CONFIRMATION)
)
result.success(response)
}
}
"getInstalledProfiles" -> {
// Query installed subscriptions
// Requires READ_PHONE_STATE permission
result.success(emptyList<String>())
}
else -> result.notImplemented()
}
}
}
iOS MethodChannel Handler
// Swift — Flutter MethodChannel handler
import Flutter
import CoreTelephony
class EsimMethodChannel: NSObject {
private let channel: FlutterMethodChannel
private let installer = EsimInstaller()
init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(
name: "com.rivio.app/esim",
binaryMessenger: messenger
)
super.init()
channel.setMethodCallHandler(handle)
}
private func handle(call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "isEsimSupported":
result(installer.supportsEsim())
case "isCarrierLocked":
// iOS doesn't expose carrier lock status directly
// We infer from supportsCellularPlan() + installed carrier
result(false)
case "installProfile":
guard let args = call.arguments as? [String: Any],
let activationCode = args["activationCode"] as? String else {
result(FlutterError(code: "MISSING_ARG",
message: "activationCode required",
details: nil))
return
}
// Parse LPA:1$address$matchingId format
let components = activationCode
.replacingOccurrences(of: "LPA:1$", with: "")
.split(separator: "$")
guard components.count >= 2 else {
result(FlutterError(code: "INVALID_FORMAT",
message: "Invalid activation code format",
details: nil))
return
}
let address = String(components[0])
let matchingId = String(components[1])
let iccid = args["iccid"] as? String ?? ""
installer.installProfile(
smdpAddress: address,
matchingId: matchingId,
iccid: iccid
) { installResult in
switch installResult {
case .success:
result(["success": true])
case .failure(let error):
result([
"success": false,
"errorCode": String(describing: error),
"errorMessage": error.localizedDescription
])
}
}
case "getInstalledProfiles":
result([String]())
default:
result(FlutterMethodNotImplemented)
}
}
}
Error Handling: What Will Go Wrong
Every production eSIM integration encounters the same failure modes. Here is how we handle each at Rivio.
eUICC Not Available
The device either lacks an eSIM chip or it is disabled. Always check before attempting installation and guide the user to QR code fallback.
// Dart — Pre-installation check flow
Future<void> startEsimInstallation(String activationCode, String iccid) async {
final esimService = EsimService();
// Step 1: Check hardware capability
final supported = await esimService.isEsimSupported();
if (!supported) {
showQrCodeFallback(activationCode);
return;
}
// Step 2: Check carrier lock
final locked = await esimService.isCarrierLocked();
if (locked) {
showCarrierLockError();
return;
}
// Step 3: Check if profile already installed
final installed = await esimService.getInstalledProfiles();
if (installed.contains(iccid)) {
showProfileAlreadyInstalledMessage();
return;
}
// Step 4: Attempt installation with retry
final result = await _installWithRetry(
esimService: esimService,
activationCode: activationCode,
iccid: iccid,
maxRetries: 3,
);
if (result.success) {
trackEvent('esim_install_success');
navigateToActivationComplete();
} else if (result.needsUserConfirmation) {
// Android: system dialog was shown
trackEvent('esim_install_user_confirmation');
} else {
trackEvent('esim_install_failure', params: {
'error_code': result.errorCode,
});
showInstallationError(result);
}
}
Future<EsimInstallResult> _installWithRetry({
required EsimService esimService,
required String activationCode,
required String iccid,
required int maxRetries,
}) async {
EsimInstallResult? lastResult;
for (var attempt = 0; attempt < maxRetries; attempt++) {
if (attempt > 0) {
// Exponential backoff: 2s, 4s, 8s
await Future.delayed(Duration(seconds: 1 << (attempt + 1)));
}
lastResult = await esimService.installProfile(
activationCode: activationCode,
iccid: iccid,
);
if (lastResult.success || lastResult.needsUserConfirmation) {
return lastResult;
}
// Don't retry on permanent failures
if (lastResult.errorCode == 'CARRIER_LOCKED' ||
lastResult.errorCode == 'PROFILE_ALREADY_INSTALLED') {
return lastResult;
}
}
return lastResult ?? EsimInstallResult.unknown;
}
Production Considerations
Background Provisioning
On Android 11+, EuiccManager.downloadSubscription() can run while the app is in the background if you hold a wake lock. We wrap the download call in a WorkManager OneTimeWorkRequest so that if the user switches apps during the SM-DP+ handshake, the download completes reliably.
Analytics and Observability
Every eSIM installation attempt at Rivio is tracked with these dimensions:
- Device model and OS version — Certain OEMs have buggy EuiccManager implementations
- SM-DP+ response time — We monitor the handshake latency to detect server-side issues
- Error code distribution — Helps us prioritize which failure modes to improve
- Retry attempt number — Tells us if transient failures are recoverable
QR Code Fallback
No matter how robust your programmatic installation is, always provide a QR code fallback. Some devices have eUICC chips that are not properly exposed through the OS APIs (we are looking at you, certain Xiaomi models). The QR code path works on every eSIM-capable device because it goes through the system LPA.
How Rivio Handles 150+ Countries with a Single Profile
Most eSIM providers issue one profile per country or region. This creates management overhead for both the provider and the user. At Rivio, we take a fundamentally different approach.
We provision a single global bootstrap profile per user. This profile is tied to our MVNO’s global IMSI, which has roaming agreements with partner MNOs across 150+ countries. When the user travels to a new country, the device attaches to a local partner network via standard GSMA roaming. Our backend Online Charging System (OCS) handles rating, balance deduction, and policy enforcement in real time.
From the Flutter app’s perspective, the eSIM installation happens exactly once during onboarding. After that, country changes are transparent — the user opens the app, sees their balance and current country, and the network handles everything else. No profile swaps, no re-provisioning, no additional API calls to install new profiles.
This architecture also means that balance never expires. Since there is one profile and one account, unused data credit carries over indefinitely. The user pays only for the bytes they consume, regardless of which country they are in.
Wrapping Up
Building eSIM installation into a Flutter app is not a trivial SDK integration. It requires native platform code on both Android and iOS, coordination with SM-DP+ providers for carrier privileges, a multi-week Apple entitlement process, and thorough error handling for a wide variety of failure modes.
The MethodChannel pattern shown above is the same architecture we use in production at Rivio. The key lessons from shipping this to real users across 150+ countries:
- Always check device capability first and provide a QR code fallback.
- Invest in carrier privilege setup early — it is the difference between a seamless install and a clunky confirmation dialog.
- Start the Apple entitlement process on day one — 8-12 weeks is not a buffer, it is the baseline.
- Track everything — OEM-specific bugs are real and you will only find them through production analytics.
If you are building a travel eSIM product, the single-profile global roaming architecture eliminates an entire category of complexity. One installation, one profile, 150+ countries. That is the model we built Rivio on, and it is what lets us offer a genuinely frictionless experience: install the app, get your eSIM, travel anywhere.