# eSIM usage

> Retrieve the remaining data, voice and SMS balance of one eSIM or of up to 50 eSIMs.

Source: https://fx-port.com/docs/esims/usage

> [!WARNING]
> **Usage is cached and is not real time.** FX-Port keeps the last usage of an eSIM for up to **15 minutes**
> to protect the supplier's rate limits. Pass `force=true` to ask the supplier again when you need the freshest number
> (for example right after a topup), but do not do it on every request: the supplier limits how often usage can be
> read and rejects clients that poll too often.


## Single eSIM usage

`GET /api/v1/esims/usage/{iccid}`

**Permission:** Read

The ICCID is 18 to 22 digits (it may be written in groups of spaces or hyphens, `8944 4654 0000 0123 456`: the digits
are read) and must belong to an eSIM **your agency ordered** in this environment. An ICCID
that is unknown, or that belongs to another agency, is answered `404 iccid_not_found`.


**cURL**

~~~bash
curl --request GET \
  --url 'https://api.fx-port.com/api/v1/esims/usage/8900000540463077957' \
  --header 'Authorization: Bearer fxp_test_YOUR_KEY'
~~~

**Node.js**

~~~js
const iccid = "8900000540463077957";
const response = await fetch(`https://api.fx-port.com/api/v1/esims/usage/${iccid}`, {
  headers: { Authorization: "Bearer fxp_test_YOUR_KEY" },
});
const body = await response.json();
const { remaining, total, expired_at, status } = body.data.data; // one level deeper than most answers
~~~

**Python**

~~~python
import requests

iccid = "8900000540463077957"
response = requests.get(
    f"https://api.fx-port.com/api/v1/esims/usage/{iccid}",
    headers={"Authorization": "Bearer fxp_test_YOUR_KEY"},
    timeout=60,
)
usage = response.json()["data"]["data"]
~~~


| Query parameter | Description |
|---|---|
| `force` | `true` skips the 15-minute cache and asks the supplier again. An empty value means not sent |

### Response example

~~~json
{
  "environment": "sandbox",
  "success": true,
  "data": {
    "data": {
      "remaining": 1024,
      "total": 1024,
      "expired_at": "2026-11-08 15:23:29",
      "is_unlimited": false,
      "status": "ACTIVE",
      "remaining_voice": 0,
      "remaining_text": 0,
      "total_voice": 0,
      "total_text": 0
    },
    "meta": { "message": "success" }
  }
}
~~~

An answer that comes from the cache adds two keys at the top level: `"cached": true` and
`"cache_age_seconds": 412` (the age of the answer in seconds). A fresh answer has neither.

### Fields of `data.data`

| Field | Meaning |
|---|---|
| `remaining`, `total` | Data left and data allowance, in **megabytes** |
| `expired_at` | When the eSIM expires: `YYYY-MM-DD HH:MM:SS` in UTC, **without a zone** |
| `is_unlimited` | `true` for an unlimited eSIM |
| `status` | The state, as the supplier reports it: `ACTIVE` (in use), `NOT_ACTIVE` (not installed or not used yet), `FINISHED` (the allowance is used up) or `EXPIRED` (the validity ended) |
| `remaining_voice`, `remaining_text`, `total_voice`, `total_text` | Voice minutes and SMS left and included. `0` for data-only packages |

### When the supplier cannot answer

If the supplier is rate limiting or unreachable and FX-Port has a previous answer for this eSIM, you get **that
answer, marked as stale**, with status `200`:

~~~json
{
  "success": true,
  "data": { "data": { "remaining": 1024, "total": 1024, "...": "..." }, "meta": { "message": "success" } },
  "cached": true,
  "stale": true,
  "stale_reason": "Rate limited",
  "retry_after": 60
}
~~~

`stale_reason` says why in a few words and `retry_after` (seconds, present when the supplier was rate limiting)
says when to ask again. Without a previous answer the call fails instead: `429 rate_limit_exceeded` with
`retry_after`, `503 service_unavailable` when the supplier connection is down, or the supplier's own status
with the message "Failed to fetch usage".

In the **sandbox**, a new eSIM shows its full allowance and the status `ACTIVE`, and the usage does not change
after a [topup](https://fx-port.com/docs/esims/topups.md).

## Bulk usage

`GET /api/v1/esims/usage/bulk`

**Permission:** Read

Returns the usage of several eSIMs in one call, for a list or a dashboard. **Always send `iccids`** with the
ICCIDs you show: the supplier is asked one eSIM after another, so a long list that is not cached takes seconds.


**cURL**

~~~bash
curl --request GET \
  --url 'https://api.fx-port.com/api/v1/esims/usage/bulk?iccids=8900000540463077957,8900000540463077965' \
  --header 'Authorization: Bearer fxp_test_YOUR_KEY'
~~~

**Node.js**

~~~js
const iccids = ["8900000540463077957", "8900000540463077965"];
const response = await fetch(`https://api.fx-port.com/api/v1/esims/usage/bulk?iccids=${iccids.join(",")}`, {
  headers: { Authorization: "Bearer fxp_test_YOUR_KEY" },
});
const { data: entries } = await response.json();
for (const entry of entries) console.log(entry.iccid, entry.status, entry.data?.data.remaining);
~~~


| Query parameter | Description |
|---|---|
| `iccids` | ICCIDs separated by commas (semicolons and line breaks work too): **at most 50** for an API key (`400 too_many_iccids` above that), each 18 to 22 digits (`400 invalid_iccid` otherwise). The parameter may also be repeated (`iccids=a&iccids=b`) or written `iccids[]=a&iccids[]=b`, and an ICCID may be written in groups of spaces or hyphens. Without it, the **50 newest eSIM orders** of your agency in this environment are read |
| `force` | `true` skips the cache for every eSIM. An empty value means not sent |

- **One entry per ICCID**, in the order you asked. An eSIM that was topped up still appears once.
- An ICCID that is not an eSIM of your agency is **left out** of the answer, without an error.
- The same 15-minute cache and stale answers as the single call apply per eSIM.

### Response example

~~~json
{
  "environment": "sandbox",
  "success": true,
  "data": [
    {
      "iccid": "8900000540463077957",
      "data": {
        "data": {
          "remaining": 1024,
          "total": 1024,
          "expired_at": "2026-11-08 15:23:29",
          "is_unlimited": false,
          "status": "ACTIVE",
          "remaining_voice": 0,
          "remaining_text": 0,
          "total_voice": 0,
          "total_text": 0
        },
        "meta": { "message": "success" }
      },
      "status": "success"
    }
  ]
}
~~~

Each entry has its own `status`. Read it per entry: the call answers `200` even when some entries failed.

| Entry `status` | Meaning | `data` |
|---|---|---|
| `success` | Fresh or cached usage | The usage, one level deeper than the single call: `data.data` |
| `stale` | The supplier could not answer: the last known usage | The last known usage |
| `rate_limited` | The supplier is rate limiting and there is no previous answer. `retry_after` says when to ask again | `null` |
| `error` | The usage could not be read. `error` is `usage_unavailable` after an unexpected failure | `null` |
| `PENDING` | The order has no eSIM yet: its ICCID starts with `PENDING-` | `null` |

Retry `rate_limited` entries later instead of calling bulk usage again at once.

## Errors

| HTTP | `code` | Cause |
|---|---|---|
| `400` | `invalid_iccid` | An ICCID is not 18 to 22 digits |
| `400` | `too_many_iccids` | More than 50 ICCIDs in `iccids` (bulk) |
| `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key |
| `404` | `iccid_not_found` | The ICCID is not an eSIM of your agency in this environment (single usage) |
| `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key, or the supplier is rate limiting and there is no previous answer (`retry_after`) |
| `503` | `service_unavailable`, `service_busy` | The supplier connection or the service is busy and there is no previous answer. Retry in a few seconds |
