# Place MetaTrader 5 orders from Python — no MetaTrader installed

URL: https://docs.fxapis.com/guides/python

> A complete Python program that connects an MT5 account, places a market order with an idempotency key, handles every failure correctly, reads positions and…



This guide builds one small Python program that trades a MetaTrader 5 account
end to end: it connects the account, brings it online, places a market order,
reads the position, closes it and takes the account offline again. It runs
anywhere Python runs — Linux, macOS, Windows, a container, a serverless
function — because there is no MetaTrader terminal on your side. The terminal
runs in our cloud; your code only makes HTTPS requests.

It uses [`requests`](https://requests.readthedocs.io/). Everything here works
the same with `httpx`.

<Callout type="warn" title="Use a demo account">
  `fx_test_` keys are a label, not a sandbox — every key reaches a real broker. Run this with a
  **broker demo account** until you are sure your code does what you expect.
</Callout>

## What you need [#what-you-need]

* Python 3.9 or newer.
* An fxapis API key with the `accounts:write`, `trading:execute` and `trading:read` scopes
  ([create one in the console](https://fxapis.com/dashboard/keys)).
* An MT5 **demo** account: its login number, its **trading** password (not the investor password),
  and the server name exactly as MetaTrader shows it, such as `ICMarkets-Demo`. See
  [Connect MT5 accounts](/guides/connect-accounts) if you are unsure which is which.

## Set up [#set-up]

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install requests

export FXAPIS_KEY="fx_test_…"          # your API key
export MT5_LOGIN="26177561"
export MT5_SERVER="VantageMarkets-Demo"
export MT5_PASSWORD="…"                # the trading password
export MT5_SYMBOL="EURUSD"             # as your broker names it
```

Keep the key and the password in environment variables or a secret store —
never in source code, and never in a log line.

## The complete program [#the-complete-program]

Save this as `trade.py` and run `python3 trade.py`.

```python title="trade.py"
"""Connect an MT5 account through fxapis, open a small trade, and close it.

Run it against a broker DEMO account. Every fxapis key reaches a real broker.
"""
import os
import sys
import time
import uuid

import requests

API = os.environ.get("FXAPIS_API", "https://api.fxapis.com")
NEEDS_ATTENTION = {"invalid_credentials", "trading_disabled", "needs_2fa", "needs_certificate"}
# Codes that mean "nothing was placed yet — ask again with the SAME key".
RETRY_SAME_KEY = {"SEND_FAILED", "IDEMPOTENCY_IN_FLIGHT", "ACCOUNT_NOT_READY", "NO_RUNTIME", "RATE_LIMITED"}

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FXAPIS_KEY']}"


class ApiError(Exception):
    def __init__(self, status, code, message, details=None):
        super().__init__(f"{status} {code}: {message}")
        self.status = status
        self.code = code
        self.details = details or []


def error_of(response):
    """The error object of a failed response, or None if the body is not ours (a proxy page)."""
    try:
        return response.json().get("error")
    except ValueError:
        return None


def raise_for(response):
    error = error_of(response) or {}
    raise ApiError(response.status_code, error.get("code", "HTTP_ERROR"),
                   error.get("message", response.text[:200]), error.get("details"))


def connect_account(login, server, password):
    """Stores the account at fxapis. It does not log in yet."""
    response = session.post(f"{API}/v1/accounts", timeout=30, json={
        "login": login,
        "server": server,
        "password": password,
        "mode": "warm_on_demand",
        "label": "python-guide",
    })
    if response.status_code == 201:
        return response.json()["data"]
    error = error_of(response) or {}
    if error.get("code") == "ACCOUNT_EXISTS":
        # Connected on an earlier run: find it instead of connecting it twice.
        for account in session.get(f"{API}/v1/accounts", timeout=30).json()["data"]:
            if account["login"] == login and account["server"] == server:
                return account
    raise_for(response)


def bring_online(account_id, timeout=120):
    """Starts the broker login, then polls until the account is ready to trade."""
    response = session.post(f"{API}/v1/accounts/{account_id}/warm", timeout=30)
    if response.status_code != 202:
        raise_for(response)  # 409 NEEDS_OPERATOR or NO_SECRET: a person has to act

    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        status = session.get(f"{API}/v1/accounts/{account_id}/status", timeout=30).json()["data"]
        if status["state"] == "ready":
            return status
        if status["state"] in NEEDS_ATTENTION:
            # Not retryable. Repeated failed logins are how brokers lock accounts.
            raise RuntimeError(f"account needs attention: {status['state']} ({status['detail']})")
        time.sleep(2)
    raise TimeoutError(f"account not ready after {timeout}s")


def wait_for_outcome(order_id, timeout=300):
    """After ORDER_UNRESOLVED: never resend. Poll the order until the broker's record settles it."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        order = session.get(f"{API}/v1/orders/{order_id}", timeout=30).json()["data"]
        if order["state"] != "unknown":
            return order
        time.sleep(5)
    raise RuntimeError(f"order {order_id} is still unknown. Do not resend it; check it again later.")


def send_order(path, body, attempts=6):
    """Places an order (or a close) exactly once, however many times the request is retried."""
    key = str(uuid.uuid4())  # one key per order, reused on every retry
    for attempt in range(attempts):
        try:
            response = session.post(f"{API}{path}", json=body, timeout=90,
                                    headers={"Idempotency-Key": key})
        except requests.RequestException:
            time.sleep(min(2 ** attempt, 10))  # we never saw the answer; the same key makes asking again safe
            continue

        if response.status_code == 201:
            return response.json()["data"]

        error = error_of(response)
        if error is None and response.status_code >= 500:
            time.sleep(min(2 ** attempt, 10))  # not our error body: a gateway hiccup
            continue
        if error is None:
            raise_for(response)

        code = error["code"]
        if code == "ORDER_UNRESOLVED":
            return wait_for_outcome(error["details"][0]["orderId"])
        if code in RETRY_SAME_KEY:
            wait = int(response.headers.get("retry-after", 0)) or min(2 ** attempt, 10)
            time.sleep(wait)
            continue
        raise_for(response)  # ORDER_REJECTED, INVALID_REQUEST, QUOTA_EXCEEDED, …
    raise RuntimeError(f"gave up after {attempts} attempts; retrying later with key {key} is still safe")


def main():
    login, server = os.environ["MT5_LOGIN"], os.environ["MT5_SERVER"]
    symbol = os.environ.get("MT5_SYMBOL", "EURUSD")

    account = connect_account(login, server, os.environ["MT5_PASSWORD"])
    account_id = account["id"]
    print(f"account {account_id} ({account['state']})")

    bring_online(account_id)
    print("online")

    try:
        order = send_order(f"/v1/accounts/{account_id}/orders/market",
                           {"symbol": symbol, "side": "buy", "volume": "0.01"})
    except ApiError as error:
        if error.code == "ORDER_REJECTED":
            print(f"rejected by the broker: {error}")
            return 1
        raise
    print(f"order {order['id']}: {order['state']} at {order['filledPrice']}")
    if order["state"] not in ("filled", "partially_filled"):
        return 1

    # Positions refresh on their own about every 15 s; reconcile (optional) refreshes them now.
    session.post(f"{API}/v1/accounts/{account_id}/reconcile", timeout=60)
    positions = session.get(f"{API}/v1/accounts/{account_id}/positions", timeout=30).json()["data"]
    for p in positions:
        print(f"  #{p['brokerPositionId']} {p['side']} {p['volume']} {p['symbol']} "
              f"@ {p['openPrice']} profit {p['profit']} (seen {p['observedAt']})")

    ticket = order["brokerPositionId"]
    close = send_order(f"/v1/accounts/{account_id}/positions/{ticket}/close", {})
    print(f"closed: {close['state']} at {close['filledPrice']}")

    # Offline now rather than after the idle window. The stored password is kept.
    session.post(f"{API}/v1/accounts/{account_id}/cool", timeout=30)

    if os.environ.get("FXAPIS_DISCONNECT") == "1":
        # Erases the stored password for good. Only when you are done with this account.
        result = session.post(f"{API}/v1/accounts/{account_id}/disconnect", timeout=30).json()["data"]
        print(f"disconnected, credentials removed: {result['credentialsRemoved']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

A run against a demo account looks like this:

```
account 0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51 (created)
online
order b1c7…9e2a: filled at 1.13426
  #2109584418 buy 0.01 EURUSD @ 1.13426 profit -0.07 (seen 2026-09-30T10:14:03.512Z)
closed: filled at 1.13419
```

The rest of this page explains each part and the decisions behind it.

## Authentication [#authentication]

Every request carries the key as a bearer token. A `requests.Session` sets it
once and reuses the connection, which also saves a TLS handshake per call:

```python
session.headers["Authorization"] = f"Bearer {os.environ['FXAPIS_KEY']}"
```

Keys are server-side credentials. Keep them out of browsers, mobile apps and
repositories. Scopes, rotation and the one-code-for-all `UNAUTHENTICATED`
response are covered in [Authentication](/authentication).

## Connecting the account [#connecting-the-account]

`POST /v1/accounts` stores the account and encrypts its password. It does
**not** log in, so it answers straight away with the account in `created`.
The `id` it returns is how you address the account from now on — store it;
never store the password.

A login and server can be connected once per workspace. On a second run the
program gets **409** `ACCOUNT_EXISTS` and looks the account up with
`GET /v1/accounts` instead. Reference:
[Connect an MT5 account](/api-reference/accounts/connect-an-mt5-account).

## Bringing it online [#bringing-it-online]

`POST /v1/accounts/{id}/warm` answers **202** at once while the broker login
happens. The program then polls `GET /v1/accounts/{id}/status` every two
seconds; `ready` usually arrives in about ten seconds in our tests.

Two states are worth handling deliberately:

* A **needs-attention** state — `invalid_credentials`, `trading_disabled`, `needs_2fa`,
  `needs_certificate` — will not improve with retries. The program stops and says why.
* `warm` itself returns **409** for an account already known to need attention, so a wrong
  password is never hammered against the broker.

A `warm_on_demand` account would also come online by itself for its first
order. Warming first simply tells you about a bad password before you try to
trade. See [Accounts](/accounts#states) for every state.

## Placing the order safely [#placing-the-order-safely]

`send_order` is the part to copy into your own code. It creates **one**
`Idempotency-Key` per order and sends that same key on every retry, so a
timeout, a dropped connection or a double click can never open a second
position. Keys are remembered for 24 hours.

Volumes and prices are strings — `"0.01"`, never `0.01`. If you compute sizes,
use `decimal.Decimal` and send `str(value)`.

| Response                                  | What it means                                                     | What `send_order` does                                                                                  |
| ----------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **201**                                   | The broker answered; `state` is `filled` (or `partially_filled`). | Returns the order.                                                                                      |
| **422** `ORDER_REJECTED`                  | The broker refused it. Nothing executed.                          | Raises. If `details[0].retryable` is true (the price moved), a *new* decision with a *new* key is fine. |
| **409** `ACCOUNT_NOT_READY`, `NO_RUNTIME` | The account was not online in time. Nothing was sent.             | Waits and retries with the same key.                                                                    |
| **409** `IDEMPOTENCY_IN_FLIGHT`           | The first attempt with this key is still running.                 | Waits and retries with the same key.                                                                    |
| **502** `SEND_FAILED`                     | Proven never to have reached the broker.                          | Retries with the same key.                                                                              |
| **502** `ORDER_UNRESOLVED`                | We do not know yet whether it executed.                           | **Never resends.** Polls `GET /v1/orders/{id}` until the order leaves `unknown`.                        |
| **429** `RATE_LIMITED`                    | Too many requests of this kind.                                   | Waits `retry-after` seconds, same key.                                                                  |
| A timeout or network error                | You did not see the answer.                                       | Retries with the same key; the server replays the first answer.                                         |

`ORDER_UNRESOLVED` is the one to get right. It means the order may be live at
the broker right now; sending it again is how accounts end up with two
positions. We confirm the result against the broker's records automatically,
and the order moves to what actually happened. The full reasoning is in
[Idempotent MT5 orders](/guides/idempotency) and
[Trading → The three failures](/trading#the-three-failures).

The request timeout is 90 seconds on purpose: an order waits for the broker's
answer, and if the account was offline it waits for the login first.

## Reading positions [#reading-positions]

`GET /v1/accounts/{id}/positions` returns open positions with
`brokerPositionId`, `side`, `volume`, `openPrice`, `currentPrice`,
`stopLoss`, `takeProfit`, `profit` and `observedAt`. It is a snapshot:
positions refresh about every 15 seconds while the account is online, and
`observedAt` says how fresh each row is. `POST /v1/accounts/{id}/reconcile`
refreshes them immediately; the program calls it right after the fill, which is
optional. Reference: [Positions on an account](/api-reference/trading/positions-on-an-account).

## Closing the position [#closing-the-position]

`POST /v1/accounts/{id}/positions/{ticket}/close` with `{}` closes all of it;
`{"volume": "0.005"}` would close part. The ticket is the position's
`brokerPositionId`, which the order response already carries.

The close goes through `send_order` too, with its own key. That matters more
than on an opening order: a close repeated on a hedging account does not close
twice — it opens an opposite position.

A close of a position opened moments ago refreshes positions first;
`POSITION_NOT_FOUND` means it is already closed. The program's `reconcile`
call after the fill is optional — it only makes the positions listing current
at once.

## Offline and disconnect [#offline-and-disconnect]

`POST /cool` takes the account offline now and keeps its stored password, so
the next order can bring it back. Without it, a `warm_on_demand` account goes
offline by itself after 15 idle minutes. Stop losses and take profits live at
the broker and keep working while it is offline.

`POST /disconnect` erases the stored password for good. It is the call to make
when a customer leaves; the program only makes it when `FXAPIS_DISCONNECT=1`,
so you can run it repeatedly. Connecting a disconnected login again brings the same account back, with its history.

## Next steps [#next-steps]

* [Signals and click-to-trade](/signals) — bring accounts online ahead of a signal with `prepare`.
* [One order across many accounts](/guides/multi-account-orders) — the same trade on up to 500 accounts in one call on Enterprise (Starter 10, Scale 100).
* [Idempotent MT5 orders](/guides/idempotency) — key patterns for real systems.
* [Errors](/errors) — every code and whether to retry it.
* [API reference](/api-reference) — every endpoint, with a console to try each one.
