Skip to content
Get in touch

Case study · Telecom / eSIM

An eSIM carrier app with built-in VoIP calling and automatic carrier switching for a New York startup

A New York startup's eSIMs were data-only. We worked with their CTO on the architecture, then built and shipped the first release of their React Native app and Python backend: eSIM management and top-ups, VoIP calling to and from local numbers on the native call screen, and carrier switching driven by background geofences, all inside an Expo-managed app. Live in the App Store and Google Play since mid-2026.

Illustration: a phone receiving a call over mobile data, with signal arcs
  1. 01 · Context and stakes

    The client is a New York startup that launched its own eSIM product. A small team of co-founders runs it, and their CTO led the direction and chose the technologies. We worked with the CTO to shape the architecture, then built and shipped the first release.

    The client sells connectivity on top of a network partner. The partner's API issues eSIMs, tops them up and reports usage. It can also change network settings on a live SIM, including which carrier the SIM attaches to. What the partner could not provide was calling: the SIMs are data-only.

    That shaped the product. A data-only eSIM is a hard sell to people who still need to call a local number and be reachable on one. And a SIM that can use several carriers only pays off if it actually moves to the stronger one where the user is. Both had to happen inside a phone app, mostly while the app was in the background.

    The client needed one app that does three jobs: manage the eSIM and the account, make and receive calls over the SIM's data, and switch carriers automatically by location.

  2. 02 · Constraints

    • Calls have to feel like phone calls. An incoming call must ring on a locked phone with the app closed, on the system call screen, on both iOS and Android.
    • Location in the background, without draining the battery. Users keep an app like this only if they never notice it running.
    • Stay on Expo's managed workflow. The CTO wanted a managed React Native project, built in the cloud, with no hand-maintained native folders, even though calling, VoIP push and background location all need native code.
    • The network partner's API is the source of truth for SIMs, balances and carrier settings, and its credentials can never live on a phone.
  3. 03 · Architecture

    The app is React Native and TypeScript on Expo, for iOS and Android. It covers eSIM install and management, data plans, in-app payments and top-ups, usage and statistics, a dialer and call history, and automatic carrier switching.

    Calling is built on an open-source SIP library. We wrapped its iOS and Android SDKs in our own React Native native module, with Swift on iOS and Kotlin on Android, behind one TypeScript API: register, call, answer, decline, hang up, and typed call-state events. Calls go over the SIP account to a hosted telephony platform that bridges them to the public phone network, so users can call local numbers and be called on one. Incoming calls wake the phone with VoIP push (PushKit on iOS, high-priority Firebase Cloud Messaging on Android) and ring on the native call screen through CallKit and Android's ConnectionService.

    Carrier switching runs on operating-system geofences. The app registers areas where the coverage data shows a stronger carrier. When the user crosses into one, the OS wakes the app, and the app asks our backend to move the SIM, which calls the network partner's API.

    The backend is Python on AWS: Lambda functions for the API and event handling, longer-running services on ECS, and DynamoDB for accounts, SIMs and switch history. It is the only component that talks to the network partner and the telephony platform. It provisions SIMs and calling accounts, handles payments and top-ups, serves usage, and owns the carrier-switch rules.

    Coverage data is third-party geospatial coverage data, kept outside AWS. It tells the backend which carrier is stronger where, and the backend turns that into the set of areas each app watches.

    App and backend

    1. Phone

      • React Native app

        Expo, iOS + Android: eSIM install, plans + top-ups, usage, dialer, settings

      • OS geofences

        Areas with a stronger carrier. Boundary crossed: the OS wakes the app, which sends a “switch carrier” request

      Down to Backend: Python on AWS, via: HTTPS, signed-in user token

    2. Backend: Python on AWS

      • Lambda

        API, events

      • ECS

        Long-running services

      • DynamoDB

      • Services

        Accounts, SIM provisioning, payments + top-ups, usage, calling accounts, carrier-switch rules

      Down to Network partner API.

    3. Network partner API

      • Network partner API

        Issue eSIM, top up, usage, change network settings (carrier)

    Calling

    1. Phone

      • Our native calling module

        Swift / Kotlin, on an open-source SIP library

      • iOS

        CallKit + PushKit

      • Android

        ConnectionService + FCM

      Down to Telephony, via: SIP over the SIM's data

    2. Telephony

      • Hosted telephony platform

      Down to Phone network.

    3. Phone network

      • Public phone network

        Local numbers

    Coverage data

    1. Source

      • Third-party geospatial coverage data

      Down to Backend, via: Coverage + geospatial layers

    2. Backend

      • Carrier-switch rules

        Python on AWS

    Build

    1. Config

      • Expo config plugins

      Down to Build.

    2. Build

      • Cloud builds

      Down to Release.

    3. Release

      • App Store

      • Google Play

    The phone app and its backend, the calling path, the coverage data behind carrier switching, and the build pipeline.
  4. 04 · Decisions

    1. Customize an open-source SIP stack instead of a paid calling SDK

      Considered
      A commercial voice SDK with per-minute pricing; an existing React Native softphone package; an open-source SIP library wrapped in our own module.
      Chose
      An open-source SIP library, wrapped in our own React Native module (Swift and Kotlin) with one TypeScript API.
      Why
      SIP straight to the client's telephony provider, with no SDK vendor between them and their margin on every minute, and full control of registration, push and the call screen.
      Trade-off
      Native code on two platforms to own, and an upstream project to follow. Its licence terms had to be settled for app-store distribution.
    2. Stay on Expo's managed workflow, with config plugins

      Considered
      Ejecting to a bare React Native app with hand-edited iOS and Android projects; keeping Expo managed and generating every native change.
      Chose
      Expo managed. Background modes, push entitlements, Android services, permissions and the calling SDK's dependencies are all written by config plugins at build time.
      Why
      The CTO wanted a project a small team can upgrade and rebuild from config alone. Native folders that nobody edits by hand can't drift.
      Trade-off
      Every native change costs more up front, because it has to be expressed as a plugin, and the plugins need upkeep with each Expo upgrade.
    3. Geofences instead of continuous location tracking

      Considered
      Polling GPS on a timer and checking coverage each time; continuous background location; OS-level geofences around stronger-carrier areas.
      Chose
      OS geofences. The app sleeps until the operating system reports that the user entered a watched area, then requests the switch.
      Why
      The OS watches the areas at low power, so the app uses close to no battery between crossings, and the location permission is easier to justify to users and app reviewers.
      Trade-off
      The OS limits how many areas an app can watch at once, and switching happens at area boundaries, not continuously.
    4. Native call screen instead of in-app-only calling

      Considered
      Ringing only while the app is open; VoIP push with the system call UI through CallKit on iOS and ConnectionService on Android.
      Chose
      The system call UI on both platforms, woken by VoIP push.
      Why
      A calling feature that rings only in the foreground is not a phone. Users expect calls on the lock screen, in the call log and alongside cellular calls.
      Trade-off
      More platform code and more to provision: VoIP push certificates, background modes and call permissions, each with its own app-review rules.
    5. A Python backend on AWS as the only door to the network partner

      Considered
      Calling the network partner's API from the app; a Node backend to share TypeScript with the app; Python on AWS.
      Chose
      Python on AWS, the CTO's choice, holding every partner and provider credential and owning the carrier-switch rules.
      Why
      Partner credentials can never ship in an app, and switching rules and coverage data can change without an app release.
      Trade-off
      Two languages across the stack, so the API contract between app and backend has to be kept explicit.
  5. 05 · Security

    • Partner and provider credentials stay on the server

      The app talks only to our backend. The network partner and telephony credentials are never shipped in the app.

    • Managed sign-in with tokens in secure storage

      A managed identity provider issues the session. Tokens are kept in the iOS Keychain and Android Keystore and sent to the backend on each request.

    • Every SIM action checked against the signed-in account

      Top-ups, usage and carrier switches are authorized on the server for the SIM that belongs to the signed-in user.

    • Location stays on the device

      Geofences are evaluated by the operating system. The backend receives a switch request, not a location history.

    • Card data handled by the payments provider

      Card details go straight to the payments provider and are never stored by our backend.

    • Signing and push credentials out of the repository

      App-signing keys and push certificates live in the build service's and CI's secret stores.

    • Least platform permission

      Microphone, call and location permissions are declared per platform by the config plugins, and only for the features that need them.

  6. 06 · Delivery

    Working with the CTO. The CTO led the direction and the technology choices. We shaped and delivered the architecture with the CTO, designing the details inside each piece and bringing the hard trade-offs back with options and a recommendation. Then we built and shipped the first release.

    1. eSIM and account. Installing the eSIM on the device, plans, payments and top-ups, and usage and statistics, all backed by the network partner's API through our backend.

    2. Calling on a data-only SIM. We first tried a commercial voice SDK and an off-the-shelf React Native softphone package. Neither gave us SIP calling we could control on both platforms inside Expo. We moved to an open-source SIP library and built our own module around it. The careful work was in the parts users never see: provisioning a calling account per user, the push certificates and VoIP push for each platform, and wiring calls into CallKit and ConnectionService so the system call screen, lock screen and audio routing behave the way people expect.

    3. Automatic carrier switching. Background location built on geofences, so the app does nothing until the user enters an area where another carrier is stronger. A cool-down stops the app from switching back and forth on a boundary.

    4. Build and release. Every native change goes through config plugins and is regenerated at each cloud build. We tested calling, push and background behaviour on real devices, because simulators can't show how a locked phone rings or how a background app is woken.

    Battery and call quality as engineering practice. Location work is left to the operating system, so the app runs only when a boundary is crossed. Calling registers with the telephony platform on demand and relies on push to wake for incoming calls, rather than holding a connection open in the background. We test calls on real networks and devices, across Wi-Fi and cellular data and through hand-overs between them.

    Launch. The first release went live in the App Store and Google Play in mid-2026.

  7. 07 · Results

    • Calling

      since calling shipped

      Before: Data-only eSIM, no phone callsAfter: Call local numbers and receive calls in the app, over the SIM's data

    • Incoming calls

      since calling shipped

      Ring on the native call screen with the app closed, on iOS and Android

    • Choosing a carrier

      since automatic switching shipped

      Before: Fixed unless changed by handAfter: Switches automatically when the user enters an area where another carrier is stronger

    • eSIM management

      at launch

      Install, plans, payments, top-ups and usage in one app

    • Availability

      since mid-2026

      First release live in the App Store and Google Play

    • Native code maintenance

      whole project

      No hand-edited native folders: every native change is a config plugin

    • A data-only eSIM became a product with calling: users can call and be called on local numbers.
    • Calling, VoIP push, the native call screen and background location all run inside an Expo-managed app.
    • Carrier switching follows the user's location without the app tracking them continuously.
    • Live in the App Store and Google Play since mid-2026.
    • 2 Blackbird engineers shaped the architecture with the client's CTO and delivered the first release over a little more than 6 months.
  8. 08 · Team

    2 Blackbird engineers, working with the client's CTO on the architecture and then on the first release. We have shipped many React Native apps. That depth is why the client gave us the native-heavy parts of an Expo app, where most of the risk sat.

    Who owned what: the client owned the product, the architecture direction, the technology choices and the relationships with the network partner and the telephony provider. We shaped the architecture with the CTO and built the first release: the mobile app, the calling module, the config plugins, the backend and the release builds. The client runs the product from there.

    What we'd do differently

    We'd set up push certificates and test incoming calls on locked devices first, before any feature screens. That path decides whether calling works at all, and it can only be checked on real devices.

10 Start

Have a similar problem? Let's talk about how.

Tell us what you're working on. We'll tell you honestly whether we're the right team for it.