Top up an eSIM
Check topup availability and top up an existing eSIM by ICCID. Creation requires a read+write API key.
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 for why.
1. Check available topup packages
GET /
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 --request GET \
--url 'https://api.fx-port.com/api/v1/esims/topup-packages/8900000540463077957?locale=en' \
--header 'Authorization: Bearer fxp_test_YOUR_KEY'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 catalogsimport 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):
{
"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 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.
2. Create the topup
POST /
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 --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": "[email protected]",
"phone": "+15555550123"
}'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: "[email protected]",
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);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": "[email protected]",
"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 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/ (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.
{
"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 and by the account history, 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 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 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?) |
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).