Docs

Place MetaTrader 5 orders from Python — no MetaTrader installed

A complete Python program that connects an MT5 account, places a market order with an idempotency key, handles every failure correctly, reads positions and closes the trade — over plain HTTP with requests, on Linux, macOS or Windows.

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. Everything here works the same with httpx.

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.

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).
  • 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 if you are unsure which is which.

Set up

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

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

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

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:

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.

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.

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 for every state.

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).

ResponseWhat it meansWhat send_order does
201The broker answered; state is filled (or partially_filled).Returns the order.
422 ORDER_REJECTEDThe 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_RUNTIMEThe account was not online in time. Nothing was sent.Waits and retries with the same key.
409 IDEMPOTENCY_IN_FLIGHTThe first attempt with this key is still running.Waits and retries with the same key.
502 SEND_FAILEDProven never to have reached the broker.Retries with the same key.
502 ORDER_UNRESOLVEDWe do not know yet whether it executed.Never resends. Polls GET /v1/orders/{id} until the order leaves unknown.
429 RATE_LIMITEDToo many requests of this kind.Waits retry-after seconds, same key.
A timeout or network errorYou 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 and 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

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.

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

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

On this page