# Top up an eSIM

> Check topup availability and top up an existing eSIM by ICCID. Creation requires a read+write API key.

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

Topping up is a two-step flow: first ask which packages can top up this exact eSIM, then create the topup
with one of the returned `package_id` values. Always follow this order: see
[Supplier continuity](https://fx-port.com/docs/esims/supplier-continuity.md) for why.

## 1. Check available topup packages

`GET /api/v1/esims/topup-packages/{iccid}`

**Permission:** Read

Lists the packages that can be added to one eSIM, with prices in your agency currency. The answer is scoped
to the ICCID, so it reflects what the supplier offers for this eSIM today, including the rare case where the
network operator of the country changed since the eSIM was issued.

The ICCID must belong to an eSIM that **your agency ordered** in the same environment: 18 to 22 digits, which may be
written in groups of spaces or hyphens (`8944 4654 0000 0123 456`; the digits are read). An ICCID
that is unknown, or that belongs to another agency, is answered `404 iccid_not_found` before anything is asked
from the supplier. A sandbox key only sees sandbox eSIMs.


**cURL**

~~~bash
curl --request GET \
  --url 'https://api.fx-port.com/api/v1/esims/topup-packages/8900000540463077957?locale=en' \
  --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/topup-packages/${iccid}?locale=en`, {
  headers: { Authorization: "Bearer fxp_test_YOUR_KEY" },
});
const body = await response.json();
const packages = body.data.data; // the list is one level deeper than the other catalogs
~~~

**Python**

~~~python
import requests

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


| Query parameter | Description |
|---|---|
| `locale` | Language of the text fields: `en`, `fr`, `ar`, `de`, `es`, `it`, `ja`, `ko`, `pl`, `pt`, `ro`, `ru`, `sq`, `tr`, `vi` or `zh`. 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). Another well-formed language is answered in English with a `coerced_value` warning; a value that is not a locale is refused with `invalid_locale`. Falls back to `Accept-Language`, then `en` |
| `force` | `true` skips the cache and asks the supplier again. An empty value means not sent |

**Caching.** The list is kept for **5 minutes** per eSIM and language. A cached answer carries `cached: true` and
`cache_age_seconds`; a fresh one has neither. Use `force=true` only when you need the very latest list.

### Response example

A sandbox answer (the `prices` block shortened):

~~~json
{
  "environment": "sandbox",
  "success": true,
  "data": {
    "data": [
      {
        "id": "france-in-7days-1gb-topup",
        "package_id": "france-in-7days-1gb-topup",
        "type": "topup",
        "price": 468.84,
        "amount": 1024,
        "day": 7,
        "is_unlimited": false,
        "title": "1 GB - 7 days",
        "data": "1 GB",
        "short_info": null,
        "voice": null,
        "text": null,
        "prices": { "recommended_retail_price": { "EUR": 3.56, "GBP": 3, "USD": 4 } },
        "base_price": 402.5,
        "commission": 50.24,
        "total_price": 468.84,
        "retail_price": 468.84,
        "balance_debit_amount": 418.6,
        "b2b_price": 418.6,
        "agency_commission": "12 %",
        "commission_rate": 12,
        "currency": "DZD",
        "supplier_currency": "USD"
      }
    ]
  }
}
~~~

An empty `data.data` array means this eSIM cannot be topped up now: place a
[new eSIM order](https://fx-port.com/docs/esims/orders.md) for the traveler instead.

### Package fields

| Field | Type | Meaning |
|---|---|---|
| `package_id` | string | The id to send as `package_id` when you create the topup. `id` carries the same value (legacy name) |
| `type` | string | Always `topup` |
| `title`, `data`, `amount`, `day` | | Label, data allowance as text, allowance in MB (`0` is unlimited) and validity in days |
| `is_unlimited`, `voice`, `text`, `short_info` | | Unlimited flag, voice minutes and SMS (`null` for data only) and a short note (usually `null`) |
| `price` | number | The **retail** price: the same amount as `total_price`. It is **not** the catalog's base price |
| `base_price` | number | The base price before your commission: the amount the catalog calls `price` |
| `commission` | number | Your commission as an **amount** |
| `total_price` / `retail_price` | number | The price to charge the traveler. Both fields have the same value |
| `balance_debit_amount` | number | The amount debited from your agency balance by the topup. `b2b_price` carries the same value |
| `agency_commission` / `commission_rate` | string / number | Your commission rate as text (`"12 %"`) and as a number (`12`; `0` for contracted corporate agencies) |
| `currency`, `supplier_currency` | string | Your agency currency (the currency of every amount) and the supplier's currency |
| `prices.recommended_retail_price` | object | The supplier's recommended retail price by currency, for reference only |

How the amounts relate is explained in [eSIM pricing](https://fx-port.com/docs/esims/pricing.md#what-price-means-in-each-response).

## 2. Create the topup

`POST /api/v1/esims/topups`

**Permission:** Read+Write

Send one `package_id` from step 1 and the ICCID you asked about. Prices are computed on the server; a price in the
body is ignored.


**cURL**

~~~bash
curl --request POST \
  --url https://api.fx-port.com/api/v1/esims/topups \
  --header 'Authorization: Bearer fxp_test_YOUR_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "package_id": "france-in-7days-1gb-topup",
    "iccid": "8900000540463077957",
    "first_name": "John",
    "last_name": "Smith",
    "email": "traveler@example.com",
    "phone": "+15555550123"
  }'
~~~

**Node.js**

~~~js
const response = await fetch("https://api.fx-port.com/api/v1/esims/topups", {
  method: "POST",
  headers: { Authorization: "Bearer fxp_test_YOUR_KEY", "Content-Type": "application/json" },
  body: JSON.stringify({
    package_id: "france-in-7days-1gb-topup",
    iccid: "8900000540463077957",
    email: "traveler@example.com",
    phone: "+15555550123",
  }),
});
const body = await response.json();
if (!response.ok) throw new Error(body.errors?.[0]?.code ?? `HTTP ${response.status}`);
console.log(body.booking_id, body.data.balance);
~~~

**Python**

~~~python
import requests

response = requests.post(
    "https://api.fx-port.com/api/v1/esims/topups",
    headers={"Authorization": "Bearer fxp_test_YOUR_KEY"},
    json={
        "package_id": "france-in-7days-1gb-topup",
        "iccid": "8900000540463077957",
        "email": "traveler@example.com",
        "phone": "+15555550123",
    },
    timeout=170,
)
body = response.json()
response.raise_for_status()
~~~


### Body fields

| Field | Type | Required | Description |
|---|---|---|---|
| `package_id` | string | **Yes** | A `package_id` returned by [topup-packages](#1-check-available-topup-packages) for **this ICCID**. 1 to 128 characters: letters, digits, `-`, `_`, `.` |
| `iccid` | string | **Yes** | The eSIM to top up: 18 to 22 digits (groups of spaces or hyphens are read), an eSIM your agency ordered |
| `email` | string | **Yes** | The traveler's email address, at most 254 characters. It is **kept on the booking only**: on a topup it is not sent to the supplier, so the supplier does not email anything to the traveler |
| `phone` | string | **Yes** | The traveler's phone number: at least 3 digits, at most 32 characters. Kept on the booking |
| `first_name` / `last_name` | string | No | Traveler name, at most 100 characters. **Any characters are accepted**, accents and non-Latin scripts included |
| `agent_id` | string (UUID) | No | The user of your agency who places the topup. It must be a user of your agency (`invalid_agent_id` otherwise). Most integrations omit it |

**Legacy names** (still work; when both spellings are sent, the new one wins):

| Legacy | Use instead | Notes |
|---|---|---|
| `user_id` | `agent_id` | The response carries a `deprecated_field` warning |
| `customer_email`, `customer_phone`, `customer_first_name`, `customer_last_name` | `email`, `phone`, `first_name`, `last_name` | The names the booking rows use. Accepted without a warning |

Send `Content-Type: application/json` (the media type is read in any case). Keys the endpoint does not read, `quantity` and every price field
included, are ignored and listed in the `warnings` of the response as `ignored_field`.

### Which package may I send?

The topup is priced from the list that step 1 returned for this ICCID, so `package_id` must come from that
list. A package that is not in it is refused with `404 not_found` ("Package ... not found or has no valid
price"), and nothing is debited. Should you get that answer for a package you have just received (for example
right after FX-Port was updated), ask for the list again with `force=true` and send the topup again.

## Response example

A sandbox topup. The response has status **200**, not 201.

~~~json
{
  "environment": "sandbox",
  "success": true,
  "data": {
    "topup": {
      "code": "20261101-106853",
      "order_id": "20261101-106853",
      "package_id": "france-in-7days-1gb-topup",
      "package": "KPN France Data only-1 GB - 7 days",
      "data": "1 GB",
      "validity": 7,
      "price": 468.84,
      "currency": "DZD",
      "total_price": 468.84,
      "retail_price": 468.84,
      "agency_currency": "DZD",
      "supplier_currency": "USD",
      "exchange_rate": 250.0
    },
    "iccid": "8900000540463077957",
    "balance": { "previous": 0.0, "current": 0.0, "currency": "DZD" }
  },
  "booking_id": "FX-1793546625391",
  "message": "eSIM topup completed successfully",
  "balanceDebited": false,
  "transactionCreated": false,
  "payoutCreated": false,
  "balance_debited": false,
  "transaction_created": false,
  "payout_created": false
}
~~~

| Field | Meaning |
|---|---|
| `data.topup.code` | The supplier's order code of the topup. `order_id` carries the same value |
| `data.topup.price` / `total_price` / `retail_price` | What the traveler pays: the retail price, in your agency currency |
| `data.topup.package_id`, `package`, `data`, `validity` | The package that was bought: id, name, allowance and validity in days |
| `data.iccid` | The eSIM that was topped up |
| `data.balance` | Your agency balance in this currency before (`previous`) and after (`current`) the debit |
| `booking_id` | The booking of the topup (`FX-` followed by a number). It is listed by [topup history](https://fx-port.com/docs/esims/bookings.md#topup-history-for-an-iccid) and by the [account history](https://fx-port.com/docs/account/history.md), not by the eSIM bookings list |
| `balanceDebited`, `transactionCreated`, `payoutCreated` | **Sandbox only**, always `false`. Twins: `balance_debited`, `transaction_created`, `payout_created` |

### In the sandbox

A sandbox topup is accepted by the supplier's test system and **debits nothing**: `balance` is `0.0` before and
after, and the three flags are `false`. The usage of a sandbox eSIM does not change after a topup. The balance check
and the duplicate check run on live only.

## Does a topup extend or replace the current plan?

A topup **adds** the package's data allowance to the eSIM: it does not replace or shorten what the traveler
already has. How the validity changes depends on the package, so FX-Port does not predict it. Read the result
from [eSIM usage](https://fx-port.com/docs/esims/usage.md) after the topup (`remaining`, `total` and `expired_at`), with
`force=true`, because usage is cached for 15 minutes.

## Duplicates and retries

There is no idempotency key. A second topup is refused with `409 duplicate_request` while an identical one
(same eSIM and package) is being processed, and on **live** also when a topup for the same eSIM was created in the
last 60 seconds, whatever the package (`existing_booking_id` names it). If a call times out, look at
[topup history](https://fx-port.com/docs/esims/bookings.md#topup-history-for-an-iccid) before you send it again.

## Errors

| HTTP | `code` | Cause |
|---|---|---|
| `400` | `missing_field` | `package_id`, `iccid`, `email` or `phone` is missing |
| `400` | `invalid_package_id`, `invalid_iccid`, `invalid_email`, `invalid_phone`, `invalid_name`, `invalid_agent_id` | A value has the wrong format or length |
| `400` | `invalid_json`, `invalid_body` | The body is not valid JSON, or is not a JSON object |
| `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key |
| `402` | `insufficient_balance` | Agency balance lower than the amount to debit (live). `details` has `available`, `required` and `currency` |
| `403` | `read_write_permission_required` | The key has read permission only |
| `403` | `agency_not_active` | The agency is not active for this environment (live) |
| `404` | `iccid_not_found` | The ICCID is not an eSIM of your agency in this environment |
| `404` | `not_found` | The package is not a topup package of this eSIM (see [Which package may I send?](#which-package-may-i-send)) |
| `409` | `duplicate_request` | An identical topup is being processed, or a topup of this eSIM was just confirmed (live) |
| `429` | `rate_limit_exceeded` | Over 1,200 requests per minute for this key |
| `429` | `concurrency_limit_exceeded` | Too many topups of your key or agency are being processed at the same time. Nothing was topped up: retry after the seconds in `Retry-After` |
| *supplier status* | `supplier_error` | The supplier refused the topup, for example because the operator no longer offers the package. The status is the supplier's, nothing was debited, and the body has the `booking_id` of the `failed` booking |
| `500` | `booking_record_failed`, `balance_debit_failed`, `supplier_response_invalid` | The supplier accepted the topup but FX-Port could not save it or take the debit, or the supplier's answer was unusable. **Do not retry**: contact support with the `booking_id` in the body |
| `503` | `service_unavailable`, `balance_lock_timeout`, `duplicate_check_unavailable`, `service_busy` | The supplier connection, the balance lock, the duplicate check or the service is busy: nothing was topped up. Retry in a few seconds |

Step 1 answers `400 invalid_iccid` or `invalid_locale`, `404 iccid_not_found`, and `503 service_unavailable` when the
supplier cannot be reached. When the supplier refuses the lookup, the answer has the supplier's status (for example
`429`).


> [!WARNING]
> If the supplier rejects the topup for any reason, including an operator change in the destination country,
> **your agency balance is never debited**. The attempt is recorded as a `failed` booking for support and the
> response is a `supplier_error` so you can react immediately.
