The usual way to automate MetaTrader 5 from Python is the official
MetaTrader5 package. It works, but it comes with a constraint people discover
late: it only runs on Windows, and it only talks to a MetaTrader 5 terminal
running on the same machine.
That means a VPS, a terminal process that has to stay logged in, and a deploy story that looks nothing like the rest of your stack. If your application is a Linux container, a serverless function, or anything not-Windows, the library is a non-starter.
This post shows the other approach: talking to MT5 over HTTP, from any language and any operating system, with the terminal running somewhere you never have to think about.
What the terminal-based approach costs you
A quick comparison, because it explains why the REST approach exists at all:
MetaTrader5 Python package | REST API | |
|---|---|---|
| OS | Windows only | Any |
| Requires a running terminal | Yes, on the same machine | No |
| Deploy target | VPS or desktop | Any container or function |
| Multiple accounts | One terminal per account | One HTTP call per account |
| Language | Python only | Anything that speaks HTTP |
The tradeoff is real in both directions: the local package gives you lower-level access to the terminal, and a REST layer adds network latency. For most applications — dashboards, analytics, copy trading, risk monitoring — the network hop is irrelevant and the operational savings are not.
Prerequisites
You need an MT5 account's login number, password, and server name, plus an API key. The examples use MetaKit, which runs an isolated terminal per account so you don't have to.
export METAKIT_KEY="stk_live_..."A read-only key is enough for everything in this post. Prefer read-only keys whenever the code only reads data — it limits the damage if the key leaks.
Step 1: find the broker
Servers are matched exactly, so don't guess the string. Search for the broker first:
import os
import requests
BASE = "https://api.metakit.cloud"
HEADERS = {"Authorization": f"Bearer {os.environ['METAKIT_KEY']}"}
brokers = requests.get(
f"{BASE}/v1/brokers", params={"q": "IC Markets"}, headers=HEADERS
).json()
for broker in brokers["data"]:
print(broker["id"], broker["name"])Keep the id — you pass it as broker_id when connecting.
Step 2: connect the account
payload = {
"name": "My Account",
"number": 40317,
"password": os.environ["MT5_PASSWORD"],
"broker_id": 210,
"server": "ICMarketsSC-Demo",
"type": "full",
}
account = requests.post(
f"{BASE}/v1/accounts", json=payload, headers=HEADERS
).json()
print(account["id"], account["status"]) # 2 provisioningThe response comes back immediately, but the account is not ready yet. This is the part most integrations get wrong.
Step 3: wait for the account to be ready
Connecting spins up a real terminal and logs it in. That takes roughly 30
seconds, and can take several minutes the first time an unusual broker is used.
The status field tells you where things stand:
| Status | Terminal? | Meaning |
|---|---|---|
provisioning | no | Preparing a broker-specific terminal image |
starting | no | Connecting to the broker |
connected | no | Ready — all endpoints work |
error | yes | Could not connect, or lost the connection |
invalid_credentials | yes | The broker rejected the login |
disconnected | yes | Stopped |
The trap: if status != "connected": fail() breaks on every single fresh
connect, because the account legitimately passes through two non-ready states
first. Wait for a terminal status instead.
import time
TERMINAL = {"connected", "error", "invalid_credentials", "disconnected"}
def wait_until_ready(account_id: int, timeout: int = 600) -> str:
"""Poll until the account reaches a terminal status."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
status = requests.get(
f"{BASE}/v1/accounts/{account_id}", headers=HEADERS
).json()["status"]
if status in TERMINAL:
return status
time.sleep(5)
raise TimeoutError(f"account {account_id} not ready after {timeout}s")
status = wait_until_ready(account["id"])
if status != "connected":
raise RuntimeError(f"Account failed to connect: {status}")Note invalid_credentials is terminal and distinct from error: retrying it is
pointless, because the login itself is wrong. Surface that one to the user
rather than looping.
Prefer webhooks over polling
Polling is fine for a script. For anything long-lived, subscribe to the
account.connected webhook and skip the loop entirely — you get told the moment
the account is live instead of asking every five seconds.
Step 4: read the account
Once connected, live figures are populated:
account = requests.get(
f"{BASE}/v1/accounts/{account['id']}", headers=HEADERS
).json()
print(f"{account['currency']} {account['balance']:,.2f}")
print(f"Equity: {account['equity']:,.2f}")
print(f"Free margin: {account['free_margin']:,.2f}")
print(f"Open trades: {account['open_trades']}")Before the account is connected, these fields are null or 0 rather than
stale values — so guard on status rather than truthiness.
Step 5: pull trade history
deals = requests.get(
f"{BASE}/v1/accounts/{account['id']}/deals",
params={"from": "2026-01-01", "limit": 100},
headers=HEADERS,
).json()
for deal in deals["data"]:
print(deal["time"], deal["symbol"], deal["type"], deal["profit"])Everything belonging to an account is nested under it —
/v1/accounts/{id}/positions, /v1/accounts/{id}/candles, and so on. The
account is always part of the path, never a query parameter.
Handling errors
Errors always come back in the same envelope, and you should branch on code
rather than the message text:
res = requests.get(f"{BASE}/v1/accounts/{account_id}/positions", headers=HEADERS)
if not res.ok:
error = res.json()["error"]
if error["code"] == "account_not_running":
# The terminal isn't connected — wait, don't retry immediately.
...
elif error["code"] == "insufficient_scope":
# A read-only key tried to write.
...
else:
raise RuntimeError(f"{error['code']}: {error['message']}")The one you'll hit most often is upstream_error (502), which almost always
means the account's terminal is not connected. Check status before retrying.
The complete script
import os
import time
import requests
BASE = "https://api.metakit.cloud"
HEADERS = {"Authorization": f"Bearer {os.environ['METAKIT_KEY']}"}
TERMINAL = {"connected", "error", "invalid_credentials", "disconnected"}
def connect_account(number: int, password: str, broker_id: int, server: str) -> dict:
account = requests.post(
f"{BASE}/v1/accounts",
json={
"name": f"Account {number}",
"number": number,
"password": password,
"broker_id": broker_id,
"server": server,
"type": "full",
},
headers=HEADERS,
).json()
deadline = time.monotonic() + 600
while time.monotonic() < deadline:
current = requests.get(
f"{BASE}/v1/accounts/{account['id']}", headers=HEADERS
).json()
if current["status"] in TERMINAL:
if current["status"] != "connected":
raise RuntimeError(f"connect failed: {current['status']}")
return current
time.sleep(5)
raise TimeoutError("account did not become ready in time")
if __name__ == "__main__":
acct = connect_account(
number=40317,
password=os.environ["MT5_PASSWORD"],
broker_id=210,
server="ICMarketsSC-Demo",
)
print(f"Connected: {acct['currency']} {acct['balance']:,.2f}")No Windows, no VPS, no terminal process to supervise. The same script runs in a Lambda, a Docker container, or a laptop.
Where to go next
- Webhooks — get pushed
position.openedandaccount.connectedevents instead of polling. Every delivery is HMAC-signed. - Analytics —
/v1/accounts/{id}/performancereturns computed Sharpe, drawdown, and win rate rather than raw rows you have to reduce yourself. - Copy trading — mirror one account onto another with configurable risk.
The full API reference lives in the documentation, and there's an llms.txt written for coding agents if you're integrating with AI assistance.