Create an eSIM order
Create a new eSIM order. Requires a read+write API key.
POST /
Permission: Read+Write
Creates one eSIM, debits your agency balance (live only) and provisions the eSIM with the supplier. The response carries the eSIM with its activation data. Pricing is always computed server-side — see eSIM pricing.
Request
Send Content-Type: application/ (the media type is read in any case).
curl --request POST \
--url https://api.fx-port.com/api/v1/esims/orders \
--header 'Authorization: Bearer fxp_test_YOUR_KEY' \
--header 'Content-Type: application/json' \
--data '{
"package_id": "france-in-7days-1gb",
"first_name": "John",
"last_name": "Smith",
"email": "[email protected]",
"phone": "+15555550123",
"description": "Order for John Smith"
}'const response = await fetch("https://api.fx-port.com/api/v1/esims/orders", {
method: "POST",
headers: { Authorization: "Bearer fxp_test_YOUR_KEY", "Content-Type": "application/json" },
body: JSON.stringify({
package_id: "france-in-7days-1gb",
first_name: "John",
last_name: "Smith",
email: "[email protected]",
phone: "+15555550123",
description: "Order for John Smith",
}),
});
const body = await response.json();
if (!response.ok) throw new Error(body.errors?.[0]?.code ?? `HTTP ${response.status}`);
const bookingId = body.booking_id; // keep it
const iccid = body.data.iccid; // keep it too: the same value as data.sim.iccidimport requests
response = requests.post(
"https://api.fx-port.com/api/v1/esims/orders",
headers={"Authorization": "Bearer fxp_test_YOUR_KEY"},
json={
"package_id": "france-in-7days-1gb",
"first_name": "John",
"last_name": "Smith",
"email": "[email protected]",
"phone": "+15555550123",
"description": "Order for John Smith",
},
timeout=100,
)
body = response.json()
response.raise_for_status()
booking_id = body["booking_id"]
iccid = body["data"]["iccid"] # the same value as data.sim.iccidBody fields
| Field | Type | Required | Description |
|---|---|---|---|
package_id | string | Yes | id from the package catalog. 1 to 128 characters: letters, digits, -, _, . |
email | string | Yes | The traveler's email address (at most 254 characters). FX-Port asks the supplier to email the QR code and a sharing link to this address |
phone | string | Yes | The traveler's phone number: at least 3 digits, at most 32 characters. No further format is checked. Kept on the booking; it is not sent to the supplier |
first_name / last_name | string | No | Traveler name, at most 100 characters. Any characters are accepted (José, Çelik-Ünal, non-Latin scripts). Kept on the booking only |
description | string | No | A note for your own records, at most 500 characters. It is sent to the supplier with the order and returned as order.description |
agent_id | string (UUID) | No | The id of the user of your agency who places the order. It is stored on the booking (agentId in the account history). It must be a user of your agency: another id is refused with invalid_agent_id. 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 | email | The name the booking rows use. Accepted without a warning |
customer_phone | phone | Same |
customer_first_name | first_name | Same |
customer_last_name | last_name | Same |
Response example
A sandbox order (long values shortened with ...). The response has status 200, not 201.
{
"environment": "sandbox",
"success": true,
"data": {
"order": {
"code": "20261101-106852",
"order_id": "20261101-106852",
"package_id": "france-in-7days-1gb",
"type": "sim",
"description": "Order for John Smith",
"esim_type": "local",
"created_at": "2026-11-01 15:23:22",
"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,
"voice": null,
"text": null,
"coverage": ""
},
"sim": {
"iccid": "8900000540463077957",
"created_at": "2026-11-01 15:23:22",
"lpa": "lpa.airalo.com",
"imsis": null,
"qrcode": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741",
"qrcode_url": "https://sandbox.airalo.com/qr?expires=1879860202&id=473564&signature=...",
"direct_apple_installation_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741",
"apn_type": "automatic",
"apn_value": "N/A",
"apn": {
"ios": { "apn_type": "automatic", "apn_value": "N/A" },
"android": { "apn_type": "automatic", "apn_value": "N/A" }
},
"is_roaming": true,
"confirmation_code": null,
"msisdn": null,
"sharing": { "access_code": 8803, "link": "https://esims.cloud/v8hy7-usgkq6wg" }
},
"iccid": "8900000540463077957"
},
"booking_id": "FX-1793546602916",
"message": "eSIM order placed successfully",
"balanceDebited": false,
"transactionCreated": false,
"payoutCreated": false,
"balance_debited": false,
"transaction_created": false,
"payout_created": false
}Save data.iccid (the same value as data.sim.iccid) — it's required for topups,
usage, and installation instructions. Save booking_id for
booking lookups.
If sim is null, the supplier has not returned the eSIM yet: the booking exists, data.iccid is the placeholder
PENDING- followed by the order code, and the data of the eSIM is available through
setup instructions once the supplier has it.
Response field reference
data.order
| Field | Type | Meaning |
|---|---|---|
code | string | The supplier's order code. order_id carries the same value: it is the name the booking rows use, and the one to use in new code |
package_id | string | The package that was ordered |
type | string | Always sim |
description | string | null | The description you sent |
esim_type | string | Kind of eSIM plan, for example local |
created_at | string | When the supplier created the order: YYYY-MM-DD HH:MM:SS, UTC, without a zone |
package | string | Name of the package: operator, data allowance and validity |
data | string | Data allowance as text |
validity | integer | Validity in days |
price / total_price / retail_price | number | What the traveler pays, in your agency currency. All three are the retail price (retail_price is the explicit name of total_price) — in the catalog price is a different amount, see eSIM pricing |
currency / agency_currency | string | Your agency currency (both fields have the same value) |
supplier_currency / exchange_rate | string / number | The supplier's currency and the rate to your currency |
voice / text | integer | null | Voice minutes / SMS included, or null |
coverage | string | Coverage text taken from the installation instructions; can be an empty string |
data.sim — the activation data is sensitive: show it only to the traveler who owns the eSIM
| Field | Type | Meaning |
|---|---|---|
iccid | string | The ICCID of the eSIM |
lpa | string | null | Host of the SM-DP+ server |
qrcode | string | null | The activation string the QR code encodes: LPA:1$<host>$<activation code> |
qrcode_url | string | null | Link to the QR code image |
direct_apple_installation_url | string | null | Link that starts the installation on an iPhone |
apn_type / apn_value / apn | string / string / object | The access point name, per platform in apn |
is_roaming | boolean | null | Whether the eSIM works through roaming |
imsis, confirmation_code, msisdn | string | null | Extra identifiers, null for most plans |
sharing | object | null | access_code and link: a page where the traveler can open the eSIM |
created_at | string | null | When the supplier created the eSIM, same format as the order |
Top level
| Field | Meaning |
|---|---|
booking_id | The FX-Port booking reference, FX- followed by a number. It is top-level, not inside data |
data.iccid | The ICCID of the eSIM, the same value as data.sim.iccid; PENDING- followed by the order code while sim is null |
message | A confirmation sentence |
balanceDebited, transactionCreated, payoutCreated | Sandbox only, always false: nothing was debited, no balance transaction and no supplier payout were created. balance_debited, transaction_created and payout_created carry the same values and are the names to use in new code. Also present on the error bodies of this endpoint |
What happens in the sandbox
A sandbox key creates a test eSIM at the supplier's sandbox: the activation code is a dummy
(DUMMY-...), the qrcode_url is on the supplier's sandbox host, no balance is checked or debited,
and the three flags above are false. The booking is stored with environment: "sandbox" and appears in
bookings and in the account history for sandbox keys only.
The balance check, the 60-second duplicate check and the balance lock run on live only.
Duplicates, timeouts and retries
There is no idempotency key. FX-Port protects you from a double order in two ways:
- while an order for the same package is being processed, a second order for that package is refused with
409 duplicate_request(the check is on the package, whoever the traveler is); - on live, an order for a package is refused with the same code when the most recent confirmed order of your
agency is for that same package and is less than 60 seconds old, and the response names it in
existing_booking_id. The traveler does not matter: two eSIMs of the same package for two travelers must be ordered at least 60 seconds apart on live (the sandbox has no such wait).
Orders usually answer in a few seconds, but a slow supplier can take much longer, and a connection closes
after about 100 seconds. If a call times out or the connection drops, do not send the order again at once:
the eSIM may exist. Look for it first with
GET / (the list is newest first), and
only order again when no booking was created.
If the supplier refuses the order, the response is a supplier_error with the supplier's HTTP status, nothing
is debited and a booking with the status failed is saved for support.
Errors
| HTTP | code | Cause |
|---|---|---|
400 | missing_field | package_id, email or phone is missing |
400 | invalid_package_id, invalid_email, invalid_phone, invalid_name, invalid_description, invalid_quantity, 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 | not_found | No package has this id, or it has no price. The message names the package_id |
409 | duplicate_request | An order for this package is being processed, or (live) the latest confirmed order of your agency is for this package and is less than 60 seconds old |
429 | rate_limit_exceeded | Over 1,200 requests per minute for this key |
429 | concurrency_limit_exceeded | Too many orders of your key or agency are being processed at the same time. Nothing was ordered: retry after the seconds in Retry-After |
| supplier status | supplier_error | The supplier refused the order. The status is the supplier's (for example 422 or 429), 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 created the eSIM but FX-Port could not save the booking or take the debit, or the supplier's answer was unusable. Do not retry: contact support with the order_code or 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 ordered. Retry in a few seconds (Retry-After tells how long when it is set) |
The 403 for a read key is returned before the body is read, so it appears even for an empty body.
When a booking is saved for a failed order, its booking_id is in the error body: use it when you contact support.