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:executeandtrading:readscopes (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 itKeep 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.
"""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.13419The 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. warmitself 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).
| 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 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
- Signals and click-to-trade — bring accounts online ahead of a signal with
prepare. - One order across many accounts — the same trade on up to 500 accounts in one call on Enterprise (Starter 10, Scale 100).
- Idempotent MT5 orders — key patterns for real systems.
- Errors — every code and whether to retry it.
- API reference — every endpoint, with a console to try each one.
Guides
Step-by-step guides for the fxapis MetaTrader 5 API: complete programs in Python, Node.js, PHP, C#, Go, Java and Ruby, connecting accounts, multi-account orders, trade copiers, idempotent orders, TradingView alerts, AI agents, prop-firm monitoring and migrating from MetaApi.
Place MetaTrader 5 orders from Node.js and TypeScript — no MetaTrader installed
A complete TypeScript program for Node.js that connects an MT5 account, places a market order with an idempotency key, retries safely, reads positions and closes the trade using the built-in fetch — no SDK, no terminal, no Windows.