EsimAccess

Elixir client for the eSIM Access wholesale eSIM reseller API.

Order, query, top up, suspend, and cancel eSIM profiles across 185+ countries. Includes typed structs for all responses, a webhook handler with Plug integration for Phoenix, and comprehensive test coverage.

Installation

Add esim_access to your list of dependencies in mix.exs:

def deps do
  [
    {:esim_access, "~> 0.1.0"}
  ]
end

Quick Start

# 1. Create a config with your access code
config = EsimAccess.new(access_code: "your_access_code")

# 2. Check your balance
{:ok, %{balance: balance}} = EsimAccess.Balance.query(config)
# balance is value * 10,000 -- so 100_000 = $10.00

# 3. Browse available packages
{:ok, packages} = EsimAccess.Packages.list(config, %{location_code: "JP"})

# 4. Order an eSIM
{:ok, order} = EsimAccess.Orders.create(config, %{
  transaction_id: "txn_#{System.os_time(:millisecond)}",
  package_info_list: [
    %{package_code: "JP_1_7", count: 1}
  ]
})

# 5. Query the allocated profile (may take up to 30s)
{:ok, {profiles, _pager}} = EsimAccess.Profiles.query(config, %{
  order_no: order.order_no,
  pager: %{page_num: 1, page_size: 50}
})

profile = hd(profiles)
# profile.ac        -> LPA activation code
# profile.qr_code_url -> QR code image URL
# profile.iccid     -> eSIM ICCID

Configuration

Create a config struct with EsimAccess.new/1. The struct is passed as the first argument to every API call -- no global state, no application config.

# Production
config = EsimAccess.new(access_code: "your_access_code")

# Custom base URL (for testing/proxying)
config = EsimAccess.new(
  access_code: "your_access_code",
  base_url: "https://your-proxy.example.com"
)

Get your access code from the eSIM Access developer console.

API Reference

All functions return {:ok, result} or {:error, %EsimAccess.Error{}}.

Packages

# All packages
{:ok, packages} = EsimAccess.Packages.list(config)

# Filter by country (Alpha-2 ISO code)
{:ok, packages} = EsimAccess.Packages.list(config, %{location_code: "US"})

# Regional or global packages
{:ok, packages} = EsimAccess.Packages.list(config, %{location_code: "!RG"})
{:ok, packages} = EsimAccess.Packages.list(config, %{location_code: "!GL"})

# Day Pass plans only
{:ok, packages} = EsimAccess.Packages.list(config, %{data_type: "2"})

# Top-up packages for a specific ICCID
{:ok, packages} = EsimAccess.Packages.list(config, %{
  type: "TOPUP",
  iccid: "89852..."
})

Orders

# Single order
{:ok, order} = EsimAccess.Orders.create(config, %{
  transaction_id: "unique_txn_id",
  package_info_list: [%{package_code: "JP_1_7", count: 1}]
})

# With price verification (fails if price changed)
{:ok, order} = EsimAccess.Orders.create(config, %{
  transaction_id: "unique_txn_id",
  amount: 15000,
  package_info_list: [
    %{package_code: "JP_1_7", count: 1, price: 15000}
  ]
})

# Daily plan with specific number of days
{:ok, order} = EsimAccess.Orders.create(config, %{
  transaction_id: "unique_txn_id",
  package_info_list: [
    %{package_code: "SG_1_Daily", count: 1, period_num: 5}
  ]
})

Profiles

# Query by order number
{:ok, {profiles, pager}} = EsimAccess.Profiles.query(config, %{
  order_no: "B23051616050537",
  pager: %{page_num: 1, page_size: 50}
})

# Query by ICCID
{:ok, {profiles, pager}} = EsimAccess.Profiles.query(config, %{
  iccid: "89852246280001113119",
  pager: %{page_num: 1, page_size: 50}
})

# Cancel unused profile (refundable)
{:ok, _} = EsimAccess.Profiles.cancel(config, %{esim_tran_no: "23120118156818"})

# Suspend / unsuspend data service
{:ok, _} = EsimAccess.Profiles.suspend(config, %{esim_tran_no: "23120118156818"})
{:ok, _} = EsimAccess.Profiles.unsuspend(config, %{esim_tran_no: "23120118156818"})

# Revoke profile (non-refundable)
{:ok, _} = EsimAccess.Profiles.revoke(config, %{esim_tran_no: "23120118156818"})

Top Up

{:ok, result} = EsimAccess.TopUp.create(config, %{
  esim_tran_no: "23072017992029",
  package_code: "TOPUP_JC172",
  transaction_id: "topup_txn_001"
})
# result.expired_time  -> new expiry
# result.total_volume  -> new total data (bytes)

Balance

{:ok, %{balance: balance}} = EsimAccess.Balance.query(config)
# balance is value * 10,000 (100_000 = $10.00)

Usage

# Check usage for up to 10 eSIMs (updated every 2-3 hours)
{:ok, usages} = EsimAccess.Usage.check(config, ["25030303480009"])
# usages -> [%{esim_tran_no, data_usage, total_data, last_update_time}]

SMS

{:ok, _} = EsimAccess.Sms.send(config, %{
  esim_tran_no: "23072017992029",
  message: "Your verification code is 123456"
})

Locations

{:ok, locations} = EsimAccess.Locations.list(config)
# type 1 = single country, type 2 = multi-country region

Webhooks

# Set webhook URL
{:ok, _} = EsimAccess.Webhook.save(config, "https://your-app.com/webhooks/esim")

# Query current webhook
{:ok, %{"webhook" => url}} = EsimAccess.Webhook.query(config)

Webhook Handling

The library provides typed event structs, a handler behaviour, and a Plug for receiving eSIM Access webhook notifications in Phoenix.

Six Event Types

Event Description
OrderStatus Order fulfilled -- profiles ready for download
EsimStatus eSIM lifecycle changes (in use, expired, etc.)
SmdpEvent Low-level SM-DP+ profile state transitions
DataUsage Data consumption threshold alerts (50%, 90%)
ValidityUsage Validity period expiry warnings (1 day left)
CheckHealth Connectivity check on initial webhook setup

1. Define a Handler

defmodule MyApp.EsimWebhookHandler do
  @behaviour EsimAccess.Webhooks.Handler

  alias EsimAccess.Webhooks.Event

  @impl true
  def handle_event(%Event.OrderStatus{order_status: "GOT_RESOURCE"} = event) do
    # Profiles allocated -- fetch ICCID and QR code
    MyApp.Esim.fetch_profiles(event.order_no, event.transaction_id)
    :ok
  end

  @impl true
  def handle_event(%Event.DataUsage{remain_threshold: threshold} = event) do
    if threshold <= 0.1 do
      MyApp.Notifications.send_low_data_warning(event.transaction_id)
    end
    :ok
  end

  @impl true
  def handle_event(%Event.ValidityUsage{remain: 1} = event) do
    MyApp.Notifications.send_expiry_warning(event.transaction_id)
    :ok
  end

  @impl true
  def handle_event(_event), do: :ok
end

2. Add the Plug to Your Router

# In your Phoenix router
forward "/webhooks/esim", EsimAccess.Webhooks.Plug,
  handler: MyApp.EsimWebhookHandler

Or with IP verification:

forward "/webhooks/esim", EsimAccess.Webhooks.Plug,
  handler: MyApp.EsimWebhookHandler,
  verify_ip: true

3. Or Parse Events Manually in a Controller

defmodule MyAppWeb.EsimWebhookController do
  use MyAppWeb, :controller

  def handle(conn, params) do
    case EsimAccess.Webhooks.Event.parse(params) do
      {:ok, event} ->
        MyApp.EsimWebhookHandler.handle_event(event)
        send_resp(conn, 200, "ok")

      {:error, _reason} ->
        send_resp(conn, 400, "invalid event")
    end
  end
end

Conventions

Prices

All prices are expressed as value * 10,000. For example, 10000 = $1.00 USD.

Data Volumes

All data volumes are in bytes. Common conversions:

Value Bytes
100 MB 104,857,600
1 GB 1,073,741,824
5 GB 5,368,709,120

Identifiers

Most profile operations accept either iccid or esim_tran_no. Prefer esim_tran_no because ICCIDs can be reused across profiles.

Rate Limits

The API enforces a limit of 8 requests per second. The client includes automatic retry with backoff for transient errors.

Error Handling

All API functions return {:error, %EsimAccess.Error{}} on failure:

case EsimAccess.Orders.create(config, params) do
  {:ok, order} ->
    # success

  {:error, %EsimAccess.Error{error_code: "200007"}} ->
    # insufficient balance

  {:error, %EsimAccess.Error{error_code: code, error_message: msg}} ->
    Logger.error("eSIM API error [#{code}]: #{msg}")
end

See EsimAccess.Error module docs for the full error code reference.

Testing

# Unit tests (no API key needed)
mix test

# Integration tests (requires access code)
ESIM_ACCESS_CODE=your_code mix test --include integration

License

MIT