Connect a MetaTrader 5 account from Python over REST

6 min readMetaKit

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 packageREST API
OSWindows onlyAny
Requires a running terminalYes, on the same machineNo
Deploy targetVPS or desktopAny container or function
Multiple accountsOne terminal per accountOne HTTP call per account
LanguagePython onlyAnything 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 provisioning

The 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:

StatusTerminal?Meaning
provisioningnoPreparing a broker-specific terminal image
startingnoConnecting to the broker
connectednoReady — all endpoints work
erroryesCould not connect, or lost the connection
invalid_credentialsyesThe broker rejected the login
disconnectedyesStopped

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.opened and account.connected events instead of polling. Every delivery is HMAC-signed.
  • Analytics — /v1/accounts/{id}/performance returns 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.