# Create an eSIM order

> Create a new eSIM order. Requires a read+write API key.

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

`POST /api/v1/esims/orders`

**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](https://fx-port.com/docs/esims/pricing.md).

## Request

Send `Content-Type: application/json` (the media type is read in any case).


**cURL**

~~~bash
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": "traveler@example.com",
    "phone": "+15555550123",
    "description": "Order for John Smith"
  }'
~~~

**Node.js**

~~~js
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: "traveler@example.com",
    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.iccid
~~~

**Python**

~~~python
import 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": "traveler@example.com",
        "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.iccid
~~~


### Body fields

| Field | Type | Required | Description |
|---|---|---|---|
| `package_id` | string | **Yes** | `id` from [the package catalog](https://fx-port.com/docs/esims/packages.md). 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 |


> [!WARNING]
> There is no `quantity` field. Every order creates **exactly one** eSIM — pricing and the agency
> balance debit only ever account for a single eSIM. `quantity` and every price field
> (`price`, `total_price`, `net_price`, `retail_price`, `base_price`, `currency`) are **ignored**: they are
> listed in the `warnings` of the response as `ignored_field`. To get more eSIMs, call this endpoint once per eSIM (on live,
> two orders of the same package must be 60 seconds apart: see [Duplicates, timeouts and retries](#duplicates-timeouts-and-retries)).

> [!WARNING]
> Only pass the traveler's own email and phone number — never billing, pricing, or account information.
> The email is sent to the supplier so that the traveler receives the QR code; the other contact fields stay in FX-Port.


## Response example

A sandbox order (long values shortened with `...`). The response has status **200**, not 201.

~~~json
{
  "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](https://fx-port.com/docs/esims/topups.md),
[usage](https://fx-port.com/docs/esims/usage.md), and [installation instructions](https://fx-port.com/docs/esims/instructions.md). Save `booking_id` for
[booking lookups](https://fx-port.com/docs/esims/bookings.md).
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](https://fx-port.com/docs/esims/instructions.md) 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](https://fx-port.com/docs/esims/pricing.md) |
| `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](https://fx-port.com/docs/esims/bookings.md) and in the [account history](https://fx-port.com/docs/account/history.md) 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 /esims/bookings?search=<traveler email>`](https://fx-port.com/docs/esims/bookings.md) (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.
