# Package catalog

> List eSIM data packages by country and type, and check device compatibility.

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

## List packages

`GET /api/v1/esims/packages`

**Permission:** Read

Returns the package catalog with prices computed for your agency, in your agency's currency.
Results are cached server-side and refreshed periodically, so repeated calls are cheap.


> [!WARNING]
> **Always send `country`.** The unfiltered catalog is about **4 MB** (more than 2,000 packages), has
> **no pagination** and takes several seconds. Ask for one destination at a time, and accept
> compressed responses (`Accept-Encoding: gzip`).


### Query parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `country` | string | No | Country or region slug, for example `france`, `turkey` or `europe`. Take it from `country_slug` of a package. A country name as people write it is read as its slug (`United States` is `united-states`, `Bosnia & Herzegovina` is `bosnia-and-herzegovina`); case and accents do not matter. An unknown slug, or a name that matches no slug, returns an empty list. |
| `type` | string | No | Scope of the package: `local` (one country) or `global` (every regional and worldwide bundle). Any other value that is a plain word (for example `regional`) returns an empty list: there is no `regional` value. A value with spaces or symbols is refused with `invalid_type`. Upper case is accepted. |
| `locale` | string | No | Language of the text fields. Falls back to the `Accept-Language` header, then `en`. Any spelling of a locale is read and only its language counts: `fr-CA`, `zh-Hans`, `pt_BR`, `fr_FR.UTF-8`, or a whole `Accept-Language` value such as `fr-FR,fr;q=0.9` (the first language is used). Supported: `en`, `fr`, `ar`, `de`, `es`, `it`, `ja`, `ko`, `pl`, `pt`, `ro`, `ru`, `sq`, `tr`, `vi`, `zh`. Another well-formed language is answered in English with a `coerced_value` warning, and `meta.locale` says `en`. A value that is not a locale is refused with `invalid_locale`. |

**Legacy names** (still work; the response carries a `deprecated_field` warning):

| Legacy | Use instead |
|---|---|
| `filter[type]` | `type` |

`all_pages` is an old parameter that does nothing: every call returns the whole filtered catalog, and the response
lists `all_pages` in its `warnings` as `ignored_field`.


**cURL**

~~~bash
curl --request GET \
  --url 'https://api.fx-port.com/api/v1/esims/packages?country=france&type=local&locale=en' \
  --header 'Authorization: Bearer fxp_test_YOUR_KEY'
~~~

**Node.js**

~~~js
const params = new URLSearchParams({ country: "france", type: "local", locale: "en" });
const response = await fetch(`https://api.fx-port.com/api/v1/esims/packages?${params}`, {
  headers: { Authorization: "Bearer fxp_test_YOUR_KEY" },
});
if (response.status === 202) {
  // the catalog is still loading after a restart: wait a few seconds and retry
}
const { data: packages, meta } = await response.json();
console.log(meta.total_packages, packages[0].package_id, packages[0].balance_debit_amount);
~~~

**Python**

~~~python
import requests

response = requests.get(
    "https://api.fx-port.com/api/v1/esims/packages",
    params={"country": "france", "type": "local", "locale": "en"},
    headers={"Authorization": "Bearer fxp_test_YOUR_KEY"},
    timeout=30,
)
body = response.json()
for package in body["data"]:
    print(package["package_id"], package["title"], package["total_price"], package["currency"])
~~~


### Response example

Example response for `GET /api/v1/esims/packages?country=france&type=local` (one of the twelve packages
shown, lists trimmed):

~~~json
{
  "environment": "sandbox",
  "success": true,
  "endpoint": "/packages",
  "data": [
    {
      "id": "france-in-7days-1gb",
      "package_id": "france-in-7days-1gb",
      "type": "sim",
      "price": 402.5,
      "base_price": 402.5,
      "amount": 1024,
      "day": 7,
      "is_unlimited": false,
      "title": "1 GB - 7 days",
      "is_fair_usage_policy": false,
      "fair_usage_policy": null,
      "data": "1 GB",
      "voice": null,
      "text": null,
      "country_name": "France",
      "country_code": "FR",
      "country_slug": "france",
      "country_image_url": "https://cdn-revamp.airalo.com/images/9753dedb-d495-47cf-b6e4-82e555564743.png",
      "operator_name": "KPN France Data only",
      "operator_id": 1704,
      "operator_style": "dark",
      "operator_gradient_start": "#F1D4FB",
      "operator_gradient_end": "#EEC0FF",
      "operator_type": "local",
      "operator_is_prepaid": false,
      "operator_esim_type": "Prepaid",
      "operator_warning": null,
      "operator_apn_type": "automatic",
      "operator_is_roaming": true,
      "operator_info": ["Data-only eSIM.", "Rechargeable online with no expiry."],
      "operator_image_url": "https://cdn-revamp.airalo.com/images/01b47d41-fbb4-4032-8f5a-d1ca6a0f3688.png",
      "operator_plan_type": "data",
      "operator_activation_policy": "first-usage",
      "operator_is_kyc_verify": false,
      "operator_rechargeability": true,
      "operator_other_info": "Covers metropolitan (mainland) France and Corsica. Does not include overseas territories (Guadeloupe, Martinique, Reunion, ...",
      "operator_coverages": [
        { "name": "FR", "code": "FR", "networks": [{ "name": "Orange", "types": ["5G"] }] }
      ],
      "operator_apn": {
        "ios": { "apn_type": "automatic", "apn_value": null },
        "android": { "apn_type": "automatic", "apn_value": null }
      },
      "is_regional": false,
      "is_global": false,
      "retail_price": 468.84,
      "total_price": 468.84,
      "balance_debit_amount": 418.6,
      "b2b_price": 418.6,
      "agency_commission": "12 %",
      "commission_rate": 12,
      "currency": "DZD",
      "supplier_currency": "USD",
      "exchange_rate": 250.0
    }
  ],
  "meta": {
    "total_packages": 12,
    "filters_applied": { "country": "france", "type": "local" },
    "locale": "en",
    "agency_currency": "DZD",
    "currency": "DZD",
    "supplier_currency": "USD",
    "exchange_rate": 250.0,
    "commission_percentage": "12 %",
    "commission_rate": 12
  }
}
~~~

Save `package_id` from the response — it's what you pass as `package_id` when [creating an order](https://fx-port.com/docs/esims/orders.md).
`id` carries the same value (it is the legacy name; keep reading it if your code already does).
The package object is described in full in the [eSIM package object](https://fx-port.com/docs/api/objects/esim-package.md).

### Response field reference

**Package identity and data allowance**

| Field | Type | Meaning |
|---|---|---|
| `package_id` | string | Package identifier — pass it as `package_id` when [creating an order](https://fx-port.com/docs/esims/orders.md). `id` carries the same value (legacy name) |
| `type` | string | Always `sim`: the package is sold as a new eSIM (topup packages have `type` `topup`) |
| `title` | string | Human-readable package label (data amount + validity) in the language of the request |
| `data` | string | Data allowance for the package, e.g. `"1 GB"` or `"Unlimited"` |
| `amount` | integer | Data allowance in MB. **`0` means unlimited** — check `is_unlimited` rather than treating `0` as "no data" |
| `is_unlimited` | boolean | `true` for unlimited-data packages |
| `is_fair_usage_policy` | boolean or integer | Whether a fair usage policy throttles the speed after a threshold. It is a boolean on limited packages and `0` or `1` on unlimited ones: treat `0` as false and `1` as true. Read `fair_usage_policy` for the exact terms |
| `fair_usage_policy` | string \| null | Human-readable fair-usage terms (e.g. throttle speed and daily threshold); `null` when no policy applies |
| `day` | integer | Validity in days (see `operator_activation_policy` for when it starts) |
| `voice` | integer \| null | Voice minutes included, when the package bundles voice (`null` = data-only) |
| `text` | integer \| null | SMS included, when the package bundles SMS (`null` = data-only) |
| `is_regional` | boolean | **Always `false`.** Regional bundles (for example `europe`) are `global` packages: check `operator_type` |
| `is_global` | boolean | `true` when `operator_type` is `global` |

**Country**

| Field | Type | Meaning |
|---|---|---|
| `country_name` | string | Destination country or region, human-readable, in the language of the request |
| `country_code` | string | Destination country, ISO 3166-1 alpha-2. An empty string for regional and worldwide bundles |
| `country_slug` | string | Destination slug — pass as the `country` query parameter. Regions have their own slug (`europe`) |
| `country_image_url` | string | Flag/country image URL for display |

**Operator (underlying mobile network)**

| Field | Type | Meaning |
|---|---|---|
| `operator_name` | string | Underlying mobile network operator/brand for this package |
| `operator_id` | integer | Identifier of the operator |
| `operator_type` | string | Package scope: `local` (one country) or `global` (regional and worldwide bundles). There is no `regional` value |
| `operator_style` / `operator_gradient_start` / `operator_gradient_end` | string | Display styling hints (light/dark theme, gradient colors) for this operator's branding |
| `operator_is_prepaid` | boolean | Whether the underlying plan is prepaid |
| `operator_esim_type` | string | e.g. `Prepaid` |
| `operator_warning` | string \| null | Operator-specific caution to show travelers (e.g. topup window), when applicable |
| `operator_apn_type` | string | `automatic` or `manual` — whether APN settings are auto-configured |
| `operator_is_roaming` | boolean | Whether this package operates via roaming rather than a local SIM profile |
| `operator_info` | string[] | Short bullet facts about the operator/network (technology, rechargeability, coverage) |
| `operator_image_url` | string | Operator logo URL |
| `operator_plan_type` | string | e.g. `data` (data-only plan) |
| `operator_activation_policy` | string | When validity starts, e.g. `first-usage` (starts on first connection, not purchase) |
| `operator_is_kyc_verify` | boolean | Whether the destination country requires identity verification for this operator (rare; relevant for a few countries) |
| `operator_rechargeability` | boolean | Whether this eSIM can be topped up — see [Top up an eSIM](https://fx-port.com/docs/esims/topups.md) |
| `operator_other_info` | string \| null | Longer free-text coverage notes/exclusions (e.g. territories not covered) |
| `operator_coverages` | array | Per-country network list: `{ name, code, networks: [{ name, types }] }` |
| `operator_apn` | object | APN settings per platform: `{ ios: { apn_type, apn_value }, android: { apn_type, apn_value } }`; `apn_value` is `null` when the setup is automatic |

**Pricing** — four different amounts, see [eSIM pricing](https://fx-port.com/docs/esims/pricing.md) for how to use them

| Field | Type | Meaning |
|---|---|---|
| `base_price` | number | **Base price** in your agency currency, before your commission. It is **not** the amount debited from your balance. `price` carries the same value (legacy name) |
| `retail_price` / `total_price` | number | The price to charge the traveler, in your agency currency. Both fields have the same value and include your commission |
| `balance_debit_amount` | number | The amount **debited from your agency balance** when you order this package. `total_price` minus this amount is your commission. `b2b_price` carries the same value (the name flights and hotels use) |
| `agency_commission` | string | Your commission **rate** as text: `"12 %"` for standard agencies, `"0 %"` for contracted corporate agencies. Bookings carry the commission as an **amount** instead. `commission_rate` carries the same rate as a number (`12` or `0`) |
| `currency` | string | Your agency's settlement currency, the currency of every amount above |
| `supplier_currency` | string | Currency the supplier prices the package in |
| `exchange_rate` | number | Rate used to convert `supplier_currency` to `currency` for this package |

### `meta` field reference

| Field | Meaning |
|---|---|
| `total_packages` | Number of packages in `data` after filtering |
| `filters_applied` | Echo of the `country` / `type` filters actually applied |
| `locale` | Language used for text fields in this response |
| `agency_currency` | Same as `currency` on each package — your agency's settlement currency. `currency` carries the same value (the canonical name) |
| `supplier_currency` | Same as `supplier_currency` on each package |
| `exchange_rate` | Same as `exchange_rate` on each package |
| `commission_percentage` | The commission rate applied across this response, as text. `commission_rate` carries it as a number |

### Caching, languages and speed

- **English** is served from a cache that is refreshed about every 50 minutes.
- **Another language** is fetched from the supplier on its first call (about 10 seconds) and then cached for an hour: the first
  request in a new language is slow, the next ones are fast. Package titles are translated; the operator facts are only partly.
- A regional bundle appears under its region slug (`country=europe`), with `operator_type` `global`.

### Errors

| HTTP | `code` | Cause |
|---|---|---|
| `202` | — | The catalog is still loading after a restart. `success` is `false` and `data` is empty: wait a few seconds and retry |
| `400` | `invalid_country`, `invalid_type`, `invalid_locale` | A parameter is not valid |
| `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key |
| `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key |

## Compatible devices

`GET /api/v1/esims/compatible-devices`

**Permission:** Read

Returns the cached list of devices known to support eSIM (about 630 entries), useful for pre-sale
device checks before a traveler purchases a package. The list is refreshed about once a week.


**cURL**

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

**Node.js**

~~~js
const response = await fetch("https://api.fx-port.com/api/v1/esims/compatible-devices", {
  headers: { Authorization: "Bearer fxp_test_YOUR_KEY" },
});
const { data: devices } = await response.json();
const supported = devices.some((device) => device.model === "iPhone15,2");
~~~


### Response example

~~~json
{
  "environment": "sandbox",
  "success": true,
  "data": [
    { "model": "T803D", "os": "android", "brand": "Alcatel", "name": "V3 Ultra" },
    { "model": "iPhone15,2", "os": "ios", "brand": "Apple", "name": "iPhone 14 Pro" }
  ],
  "meta": {
    "total_devices": 631,
    "cached_at": "2026-11-01T15:07:43.957236",
    "cache_age_days": 0.01
  }
}
~~~

### Response field reference

| Field | Meaning |
|---|---|
| `brand` | Device manufacturer, spelled as the supplier does (the case differs between entries) |
| `model` | Manufacturer's internal model identifier |
| `name` | Human-readable marketing name for the device |
| `os` | Operating system family: `ios` or `android` |
| `meta.total_devices` | Number of devices in `data` |
| `meta.cached_at` | When the list was fetched from the supplier: the server's clock **without a zone** (`2026-11-01T15:07:43.957236`), unlike the UTC timestamps of the bookings |
| `meta.cache_age_days` | Age of the list in days |


> [!NOTE]
> Right after a restart the list can still be loading in the background: the call answers **`202`** with
> `"success": false` and an empty `data` array, or **`503`** while the device fetcher itself is not
> running yet. Retry after a few seconds.
