# EsimAccess
Elixir client for the [eSIM Access](https://esimaccess.com) 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`:
```elixir
def deps do
[
{:esim_access, "~> 0.1.0"}
]
end
```
## Quick Start
```elixir
# 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.
```elixir
# 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](https://console.esimaccess.com/developer/index).
## API Reference
All functions return `{:ok, result}` or `{:error, %EsimAccess.Error{}}`.
### Packages
```elixir
# 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
```elixir
# 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
```elixir
# 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
```elixir
{: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
```elixir
{:ok, %{balance: balance}} = EsimAccess.Balance.query(config)
# balance is value * 10,000 (100_000 = $10.00)
```
### Usage
```elixir
# 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
```elixir
{:ok, _} = EsimAccess.Sms.send(config, %{
esim_tran_no: "23072017992029",
message: "Your verification code is 123456"
})
```
### Locations
```elixir
{:ok, locations} = EsimAccess.Locations.list(config)
# type 1 = single country, type 2 = multi-country region
```
### Webhooks
```elixir
# 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
```elixir
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
```elixir
# In your Phoenix router
forward "/webhooks/esim", EsimAccess.Webhooks.Plug,
handler: MyApp.EsimWebhookHandler
```
Or with IP verification:
```elixir
forward "/webhooks/esim", EsimAccess.Webhooks.Plug,
handler: MyApp.EsimWebhookHandler,
verify_ip: true
```
### 3. Or Parse Events Manually in a Controller
```elixir
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:
```elixir
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
```bash
# Unit tests (no API key needed)
mix test
# Integration tests (requires access code)
ESIM_ACCESS_CODE=your_code mix test --include integration
```
## License
MIT