Skip to content
← All posts
Engineering

Implementing eSIM in Flutter: A Complete Technical Guide

Berin Doğa Alaca Berin Doğa Alaca ·
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 1 indicates 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:

  1. Your app’s package name (e.g., com.rivio.app)
  2. 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 ':'

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:

  1. Contact Apple. Reach out to your Apple Sales Representative or use the Apple MFi Program contact channels if you are an existing partner.
  2. Submit your business case. Apple needs to understand your SM-DP+ infrastructure, your carrier agreements, and your intended user experience.
  3. Technical review. Apple’s team evaluates your integration, usually requiring a TestFlight build demonstrating the flow.
  4. Entitlement grant. Once approved, the entitlement is added to your App ID in your Apple Developer account.
  5. 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:

  1. Always check device capability first and provide a QR code fallback.
  2. Invest in carrier privilege setup early — it is the difference between a seamless install and a clunky confirmation dialog.
  3. Start the Apple entitlement process on day one — 8-12 weeks is not a buffer, it is the baseline.
  4. 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.

Berin Doğa Alaca

Berin Doğa Alaca

Frontend Tech Lead

Berin Doğa Alaca is the Frontend Tech Lead at Rivio. She architects and implements the web and mobile interfaces that millions of travelers interact with, ensuring pixel-perfect designs and blazing-fast performance across all platforms.