Most retail forex and CFD brokers still hand you a MetaTrader 4 or 5 login, and MetaTrader is surprisingly hard to automate from normal code. Your options are roughly:
- write MQL and live inside the terminal,
- use the official
MetaTrader5Python package, which only covers MT5 and needs a Windows machine with the terminal installed and running next to your script, - or glue something together yourself with an Expert Advisor, files and sockets.
Whichever you pick, you end up keeping Windows machines running around the clock, with a terminal logged in for every account you trade.
FxSocket is what I built instead. You give it a broker login, it starts a dedicated MT4 or MT5 terminal for that account in the cloud, and you get a REST and WebSocket API back. It works with any broker that offers MT4 or MT5, on demo and live accounts, from any language that can make an HTTP request.
Disclosure up front: I built this, and it's a paid product. I've tried to make the post useful even if you never sign up. After the code samples, most of it is about the engineering: API rules that came from worrying about double fills, and how MetaTrader runs headless on Kubernetes.
What you get
- A dedicated terminal per account. There's no shared gateway multiplexing everyone's logins; your requests run inside your own MetaTrader instance.
- REST endpoints for balance and equity, open positions and pending orders, deal and position history, symbols, quotes, OHLC history and contract specs, plus order send, modify and close, and margin and profit calculators.
- One WebSocket per account with six topics: ticks, bar closes, account, positions, trade events and terminal status.
- A management API (
/v1) to link and unlink accounts, send one order to many accounts at once, and create read-only keys scoped to specific accounts. - Official SDKs for Python (sync and async, Pydantic models) and Node/TypeScript (ESM and CommonJS), both MIT licensed.
- An OpenAPI 3 spec and Swagger UI on every account, so you can generate a client for Go, Rust, C# or anything else.
FxSocket is not a broker. Your money stays with your broker, and FxSocket has no way to deposit, withdraw or move it.
From zero to a first order with curl
Grab an API key from the dashboard, then link an account. server is the broker server name exactly as MetaTrader shows it.
export FXSOCKET_API_KEY=fxs_live_...
curl -X POST https://api.fxsocket.com/v1/accounts \
-H "X-API-Key: $FXSOCKET_API_KEY" \
-H "Content-Type: application/json" \
-d '{"platform": "mt5", "server": "VTMarkets-Demo", "login": 1234567, "password": "..."}'
The password is stored encrypted and never returned by the API. (An investor password is enough for read-only access; placing trades needs the master password.) The response, trimmed:
{
"id": "b5236acf-c39b-4e8b-be64-a96afb7e6086",
"platform": "mt5",
"server": "VTMarkets-Demo",
"login": 1234567,
"status": "connecting"
}
connecting means a real terminal is booting and logging in to your broker. Poll GET /v1/accounts/{id} until it reads connected, which usually takes a few minutes.
Every per-account route hangs off /mt5/{account_id} (MT4 accounts use /mt4/{account_id} with the same routes):
ACCOUNT=b5236acf-c39b-4e8b-be64-a96afb7e6086
curl -H "X-API-Key: $FXSOCKET_API_KEY" \
https://api.fxsocket.com/mt5/$ACCOUNT/AccountSummary
{
"balance": 9686.15,
"credit": 0.0,
"profit": 0.0,
"equity": 9686.15,
"margin": 0.0,
"freeMargin": 9686.15,
"marginLevel": 0.0,
"leverage": 400,
"currency": "USD",
"type": "Demo"
}
A market buy with a stop loss and take profit:
curl -X POST https://api.fxsocket.com/mt5/$ACCOUNT/OrderSend \
-H "X-API-Key: $FXSOCKET_API_KEY" \
-H "Content-Type: application/json" \
-d '{"symbol": "EURUSD", "operation": "Buy", "volume": 0.1, "stopLoss": 1.148, "takeProfit": 1.162}'
{
"success": true,
"retcode": 10009,
"retcodeDescription": "Request completed",
"deal": 211438760,
"order": 211438761,
"volume": 0.1,
"price": 1.15333,
"bid": 1.15325,
"ask": 1.15333,
"comment": ""
}
Internalize this one early: an HTTP 200 doesn't mean the order filled. It means MetaTrader answered. Check success and retcode (10009 is done, 10008 is placed). That distinction ended up shaping a lot of the API (more on that below).
Pending orders use the same route with BuyLimit, SellStop and friends, plus a price. OrderModify changes SL/TP (or a pending order's price), and OrderClose closes by ticket, with an optional volume for a partial close.
Streaming over WebSocket
Each account has one WebSocket. Browsers can't set custom headers on a WebSocket upgrade, so the key goes in the query string:
$ wscat -c "wss://api.fxsocket.com/mt5/$ACCOUNT/ws?api_key=$FXSOCKET_API_KEY"
> {"action": "subscribe", "topic": "prices", "symbol": "EURUSD"}
< {"type": "tick", "symbol": "EURUSD", "data": {"bid": 1.15325, "ask": 1.15333, "last": 0.0, "volume": 0, "time": "2026-06-11T08:55:56.728Z"}}
Topics are prices, bars (per symbol and timeframe), account, positions, trades and terminal. When you subscribe you get a snapshot first, then deltas.
Python SDK
pip install fxsocket
from fxsocket import Client
with Client() as fx: # reads FXSOCKET_API_KEY
account = fx.accounts.list()[0]
term = fx.terminal(account)
print("equity:", term.account_summary().equity)
print("EURUSD ask:", term.quote("EURUSD").ask)
result = term.order_send(
symbol="EURUSD", operation="Buy", volume=0.10,
stop_loss=1.148, take_profit=1.162,
)
if result.success:
term.order_modify(result.order, take_profit=1.165)
fx.terminal(account) looks up where that account's terminal lives, so the same code works on the shared cluster and on a private server (more on those later).
Streaming is async-first. A dropped connection reconnects on its own and replays your subscriptions:
import asyncio
from fxsocket import AccountUpdate, AsyncClient, Tick
async def main():
async with AsyncClient() as fx:
account = (await fx.accounts.list())[0]
async with fx.stream(account) as s:
await s.subscribe_prices("EURUSD")
await s.subscribe_account()
async for event in s:
match event:
case Tick():
print(event.symbol, event.data.bid, event.data.ask)
case AccountUpdate():
print("equity", event.data.equity)
asyncio.run(main())
Node / TypeScript SDK
npm install @fxsocket/sdk
import { FxSocket } from '@fxsocket/sdk';
const fx = new FxSocket(); // reads FXSOCKET_API_KEY
const [account] = await fx.accounts.list();
const stream = fx.stream(account);
stream.on('tick', (e) => console.log(e.symbol, e.data.bid, e.data.ask));
stream.on('trade', (e) => console.log('position', e.data.position, 'net', e.data.netProfit));
await stream.connect();
await stream.subscribePrices('EURUSD');
await stream.subscribeTrades();
Stream events are a discriminated union on type, so TypeScript narrows data for you. The stream is also an async iterable if you'd rather use for await.
One order, many accounts
Once you run more than a couple of accounts (copy trading, a stack of prop firm challenges, one strategy spread across brokers), you want one request instead of N. POST /v1/orders fans an order out to several terminals concurrently:
curl -X POST https://api.fxsocket.com/v1/orders \
-H "X-API-Key: $FXSOCKET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signal-4711" \
-d '{
"defaults": {"operation": "buy", "symbol": "EURUSD", "stop_loss": 1.148},
"orders": [
{"account_id": "6fcb1e23-...", "volume": 0.10},
{"account_id": "a91b0d47-...", "volume": 0.25, "symbol": "EURUSD.sd"},
{"account_id": "c73d55e2-...", "volume": 0.05, "symbol": "EURUSDm"}
]
}'
Each leg inherits defaults and can override any field. The per-leg symbol is there for a boring but very real reason: brokers spell instruments differently. EURUSD, EURUSD.sd and EURUSDm are the same pair at three different brokers, so one symbol across mixed brokers will partly fail by design.
The response has one result per leg, in order, and the statuses are where it gets interesting:
status |
Meaning | Executed? |
|---|---|---|
filled |
The broker accepted it | Yes |
rejected |
The broker refused it (see retcode) |
No |
invalid |
The terminal refused the request itself | No |
unavailable |
The terminal is up but not ready to trade | No |
unreachable |
There's no terminal to talk to | No |
timeout |
The deadline ran out | Unknown |
Validation is all-or-nothing: one malformed leg rejects the whole batch before anything is sent. Execution isn't. A partial fill across brokers is normal rather than an error, so you get a 200 with a result for every leg.
Closing works by intent rather than by ticket. POST /v1/orders/close takes a selector like {"symbol": "EURUSD", "side": "long"}, looks up matching positions on each account and closes them. You don't have to fetch tickets first from a list that may already be stale by the time you act on it.
Lessons from designing a trading API
A timeout is not a failure
The most important row in that table is the last one. When a request to a broker times out, the order may well have gone through. If your client calls that a failure and retries, you've just doubled your position. That's why timeout is reported as unknown, never as failed.
The batch endpoints take an Idempotency-Key header for the same reason. Replay the identical body with the same key within 15 minutes and you get the original response back, with nothing sent. The same key with a different body gets a 422, and the same key while the first batch is still running gets a 409. If the idempotency store itself is down, the request is refused with a 503 rather than traded without the guarantee.
Make the dangerous thing hard to do by accident
A few rules that came from imagining the worst possible afternoon:
- A close needs a symbol, and "everything" has to be typed. An omitted
symbolis a400, never a silent close-all. If you mean every symbol, you send"*". - A close only touches open positions by default, so a routine close doesn't also delete your resting pending orders.
- Unknown fields are rejected.
/v1uses snake_case and the terminal routes use camelCase. If someone pastesstopLossinto a/v1body and it gets silently ignored, they've just opened a position with no stop. So it's a400instead. - The SDKs refuse a stop loss of
0on a modify. MetaTrader reads a zero stop loss as "remove the stop", so the SDK makes you ask for that explicitly withclear_stop_loss=True.
"Done" has more than one shape
MetaTrader returns a result code for every trade request, and the SDKs classify each one as applied, no_change, partial or rejected. no_change (retcode 10025, meaning the SL/TP you asked for is already set) comes back with success: false, yet it's exactly what you want when you re-send a modification after losing the first response. So results also carry is_effective, which is true for both applied and no_change and makes SL/TP updates safe to retry.
Always bound your history requests
On MT5, ask for price history without a date range and the terminal asks the broker for the entire history of that timeframe. On a typical demo account that's around a million M1 bars, which blows straight through the request deadline. Pass a from date and the same call answers in well under a second.
Under the hood: MetaTrader on Kubernetes
MetaTrader is a Windows desktop application. There's no headless mode, and the official ways in are MQL running inside the terminal or (for MT5) a Python package that talks to a terminal on the same Windows machine. Here's how it runs as a service anyway.
One terminal per account, in a container
On the shared cluster, every linked account gets its own pod running the real MetaTrader terminal under Wine, with Xvfb standing in for a display.
A Rust DLL inside the terminal
The bridge is a Rust library loaded into the terminal process. It runs a tokio runtime with an axum server for REST and WebSocket. MQL programs inside the terminal drain a command queue on the terminal's main thread, call MetaTrader's own functions, and pass the results back across the FFI boundary. The OpenAPI spec is generated from the Rust types with utoipa, which is how every account gets an accurate Swagger UI for free.
MQL5 has no "attach this Expert Advisor" function
This one surprised me. To start the trading EA, the bridge opens a chart and applies a chart template that has the EA on it. Streaming uses the same trick: the first subscriber to a symbol's ticks spawns a small event EA on a chart for that symbol, and when the last subscriber leaves, the chart closes. Events fan out through a tokio broadcast channel, and a slow WebSocket client gets dropped messages plus a warning frame instead of stalling everyone else.
Never run two terminals on one login
Brokers treat two concurrent sessions on the same account as a duplicate login and kick one of them, and trade state gets murky. A normal Kubernetes rolling update briefly runs the old and new pod side by side, which is exactly that situation. So terminal Deployments use the Recreate strategy. The cost is a short gap during image rollouts, and I'll take that trade.
Health checks can't depend on the broker
The terminal's own health route needs a live broker session. If the liveness probe used it, a terminal that's simply mid-login (or a broker having a bad minute) would get restarted in a loop. So the Kubernetes probes only check that the port is open, and a reconcile loop checks the real thing every couple of minutes: terminal alive, broker connected, account logged in. The same loop creates missing terminals, heals half-created ones and sweeps up orphans, so a half-finished teardown never leaves broker credentials lying around.
Config changes roll the pod on purpose
A terminal reads its config once, at boot. The pod template carries a hash of that config (credentials, keys, image), so rotating an API key changes the hash, and the next reconcile pass restarts the terminal with the new key.
Broker discovery is its own project
A fresh MT5 install can't resolve arbitrary broker server names. Without that broker's servers.dat, the terminal boots, the API answers, and it never even attempts a login. You just get an account summary full of zeros. So a separate broker search service keeps an index of more than 9,000 broker servers, and each container installs the right file before the terminal starts.
Related lesson: a truncated servers.dat can make the terminal's parser balloon until it OOM-kills a 4 GiB pod in under five seconds. Verify your uploads.
Auth lives in the terminal, not the gateway
The gateway only routes. Each terminal starts with its owner's API key (plus any read-only keys scoped to that account) and checks every request itself. A perfectly valid key for account A, presented at account B's URL, is just a wrong key and gets a 401. There's no permission lookup to misconfigure, because the terminal doesn't know anyone else's keys.
Isolation costs memory
Each terminal reserves 1 GiB, even though steady-state usage sits around 300 to 500 MB. One terminal per account is simple to reason about and hard to get wrong, but memory is the real scaling cost of this design.
Private servers with a fixed IP
Some prop firms want to whitelist the IP your trades come from, and some traders simply want their own box. For that there are private servers: a dedicated server in a region you pick, with a fixed IP that survives reboots, resizes and rebuilds. A new one is live in 5 to 10 minutes and speaks the exact same REST, WebSocket and SDK API.
Pricing
At the time of writing:
- Starter: €12 per account per month, full REST and WebSocket access, live and demo accounts
- Pro: €549 per month, 60 accounts included
- Premium: €949 per month, 110 accounts included
- Private server: €17 per month for the first account, plus €14 for each additional account on that server, billed separately from your plan
You can pay by card, or prepay a balance in crypto that covers your accounts as they renew.
Try it
- Website: fxsocket.com
- Docs: fxsocket.com/docs
- Management API reference (Swagger): api.fxsocket.com/v1/docs
- Python SDK: PyPI and GitHub
- Node SDK: npm and GitHub
You don't need a funded account to try any of this. A free demo account from your MT4 or MT5 broker works with every endpoint in this post. cTrader support is on the roadmap.
If you've automated MetaTrader some other way, I'd like to hear what hurt. And if you think one of the design choices above is wrong, tell me in the comments.
Trading leveraged products carries a real risk of loss. Nothing in this post is financial advice.
Top comments (0)