Development · 13 min read · 2026-06-09
How to Build a Copy Trading Platform with MT5 REST API
By MetaTrader API Editorial Team

Build a copy trading platform on MT5 with a REST API and WebSocket: master detection, risk engine, follower execution and scaling, with real Python code.
Copy trading is one of the fastest-growing fintech verticals in the world. Platforms like eToro, ZuluTrade, and NAGA have proved there is enormous demand: traders want to follow experts, and profitable traders want to monetize their edge. The question is — how do you build the infrastructure behind it yourself?
The answer is not a white-label solution that locks you into someone else's fee structure, subscriber data, and feature roadmap. The answer is a REST API + WebSocket bridge between your master account and your follower accounts, running on your own server, under your own brand.
This guide walks you through the complete technical architecture — from detecting a master trade in real time to executing it proportionally across dozens of follower accounts — using MetaTrader API and Python.
No Expert Advisor for you to run, no MQL5, and nothing to install on your side.
What You Are Actually Building
A copy trading platform has three core components:
The Signal Source — a master MT5 account whose trades you detect in real time
The Copy Engine — your server that applies risk rules and replicates trades
The Follower Layer — one or more subscriber MT5 accounts that receive executions
MetaTrader API provides the bridge between all three. Your server is the only custom piece you write.
Prerequisites
A MetaTrader API account (per-seat pricing: one seat per connected account, $14 per month for seats 1–4, $13 for seats 5–19 and $12 from the 20th seat; up to 50 seats per customer, more on request; there is no free trial)
A master MT5 account (any broker, demo or live)
At least one follower MT5 account to test with
Python 3.9+ with
requestsandwebsocket-clientinstalled (pip install requests websocket-client)Two keys from the MetaTrader API dashboard: an API key (
mt5api_..., sent as thex-api-keyheader) and a management key (mt5mgmt_..., Settings → "Create management key"), which connects accounts from your backend

Part 1 — Connecting the Master and Follower Accounts
Every account is connected once. From a backend that onboards users, use POST /v1/seats with your management key: it returns the account id (a UUID) that identifies that account as ?id= in every later call. (/ConnectEx?user=&password=&server=&platform=MT5 does the same for code written to the classic MetaTrader REST conventions, but it puts the broker password in the URL.) You end up with one account id per account — master and each follower.
import os, time, requests
API = "https://api.metatraderapi.net"
WS = "wss://api.metatraderapi.net"
API_KEY = os.environ["MTAPI_KEY"] # API key, not account-scoped: it can address all your accounts via ?id=
MGMT_KEY = os.environ["MTAPI_MGMT_KEY"] # management key: creates and removes seats
api = requests.Session()
api.headers["x-api-key"] = API_KEY
mgmt = requests.Session()
mgmt.headers["Authorization"] = f"Bearer {MGMT_KEY}"
def connect(mt_login: int, mt_password: str, mt_server: str, label: str) -> str:
"""Connect an MT5 account and wait until it is ACTIVE. Returns its account id."""
r = mgmt.post(f"{API}/v1/seats", json={
"label": label,
"platform": "MT5",
"brokerServer": mt_server, # exactly as the broker spells it
"brokerLogin": str(mt_login),
"brokerPassword": mt_password, # the main (trading) password
}, timeout=75)
r.raise_for_status()
account_id = r.json()["seat"]["id"]
for _ in range(60): # usually ACTIVE within 1-2 minutes
seat = mgmt.get(f"{API}/v1/seats/{account_id}", timeout=75).json()["seat"]
if seat["status"] == "ACTIVE":
print(f"[{label}] connected: {account_id}")
return account_id
if seat["status"] == "ERROR":
# the reason is reported by /ConnectState (the seat object has no statusReason)
state = mgmt.get(f"{API}/ConnectState", params={"id": account_id}, timeout=75).json()
raise RuntimeError(f"[{label}] connection failed: {state.get('statusReason')}")
time.sleep(3)
raise TimeoutError(f"[{label}] not ACTIVE after 3 minutes")
# Connect the master account
MASTER_ID = connect(12345678, "master_pass", "YourBroker-Live", "MASTER")
# Connect follower accounts
FOLLOWERS = {
"follower_alice": connect(12345001, "alice_pass", "YourBroker-Live", "ALICE"),
"follower_bob": connect(12345002, "bob_pass", "AnotherBroker-Real", "BOB"),
}
Pro tip: Store the account ids in your database. They are permanent: dropped broker connections are restored automatically, so restarting your own server needs no reconnect at all. If /ConnectState?id= ever reports DISCONNECTED for an account, call /Reconnect?id= instead of creating a new seat.
Part 2 — Building the Real-Time Detection Engine
Once the master account is connected, you need to see every trade it opens, modifies, or closes — in real time. One WebSocket per account, wss://api.metatraderapi.net/v1/stream?id=<account id> with the x-api-key header, carries all of it. No subscription call is needed for trade events:
TRADEframes — one per executed deal: opens (entry: "IN"), full and partial closes (entry: "OUT"), stop-loss and take-profit hitsPOSITIONSframes — the full list of open positions, withslandtp, whenever it changes (the first one after connecting is a snapshot of the current state)
Every frame has the shape {"type": "...", "data": ...}.
import json, threading
import websocket # pip install websocket-client
def start_master_listener(account_id: str, on_trade, on_positions):
"""
Open the account's stream. Calls on_trade(deal) for every executed deal and
on_positions(list) whenever the open positions change. Reconnects on its own.
"""
def on_message(ws, raw):
msg = json.loads(raw)
if msg["type"] == "TRADE":
on_trade(msg["data"])
elif msg["type"] == "POSITIONS":
on_positions(msg["data"])
elif msg["type"] == "HEARTBEAT" and msg["data"]["connection"] != "connected":
print("[MASTER WS] stream link:", msg["data"]["connection"])
ws = websocket.WebSocketApp(
f"{WS}/v1/stream?id={account_id}",
header={"x-api-key": API_KEY},
on_message=on_message,
on_error=lambda ws, err: print(f"[MASTER WS] error: {err}"),
on_close=lambda ws, code, reason: print(f"[MASTER WS] closed {code} {reason}"),
)
# Background thread keeps the main process free; reconnect=5 retries after 5 s
threading.Thread(target=ws.run_forever, kwargs={"reconnect": 5}, daemon=True).start()
print("[MASTER WS] listener started")
When your master trader opens a EURUSD position, the socket delivers a frame like this:
{
"type": "TRADE",
"data": {
"deal": 51230987, "order": 294871023, "position": 294871023, "ticket": 51230987,
"symbol": "EURUSD", "type": "BUY", "entry": "IN", "volume": 1.0, "price": 1.0842,
"commission": -3.5, "swap": 0.0, "profit": 0.0, "fee": 0.0,
"magic": 0, "expertId": 0, "comment": "breakout strategy",
"time": "2026-10-12T12:15:02Z", "timeUtc": "2026-10-12T09:15:02Z"
}
}
entry tells you what happened: "IN" opened a position, "OUT" closed all or part of one. position is the id of the position the deal belongs to — that is the key you map follower positions to. Stop-loss and take-profit changes are not deals: you see them in the next POSITIONS frame. (The engine below assumes hedging accounts, where every open is its own position.)
Part 3 — The Risk Engine
This is the most important component. A naive copy engine that blindly mirrors every trade at 1:1 volume will blow up followers with different account sizes. A professional copy trading platform always applies a risk calculation layer.
The two most common approaches are:
A) Equity-Ratio Lot Sizing
The follower's lot size is proportional to their account equity relative to the master:
follower_lots = master_lots × (follower_equity / master_equity)
B) Fixed Multiplier
Each follower has a personal multiplier (e.g., 0.5× or 2×) configured at sign-up.
Both require knowing the follower's current equity, which you fetch with /AccountSummary:
def get_account_summary(account_id: str) -> dict:
"""Live balance, equity, margin and free margin of one account."""
r = api.get(f"{API}/AccountSummary", params={"id": account_id}, timeout=75)
r.raise_for_status()
return r.json() # balance, equity, margin, freeMargin, marginLevel, currency, ...
def calculate_follower_lots(
master_lots: float,
master_equity: float,
follower_equity: float,
min_lots: float = 0.01,
max_lots: float = 10.0,
) -> float:
"""Equity-ratio proportional lot sizing with floor/ceiling enforcement."""
if master_equity <= 0:
return min_lots
raw = master_lots * (follower_equity / master_equity)
sized = round(raw, 2) # round to the symbol's volumeStep in production (/SymbolParams)
return max(min_lots, min(sized, max_lots))
def should_replicate(summary: dict, min_free_margin: float = 100.0) -> bool:
"""
Safety gate: skip replication if the follower account is underfunded
or the margin level is dangerously low.
"""
if summary["freeMargin"] < min_free_margin:
print(f"[RISK] Skipping — free margin too low: {summary['freeMargin']}")
return False
# marginLevel is 0 while no margin is in use, so only check it when there is some
if summary["margin"] > 0 and summary["marginLevel"] < 150: # below 150% = danger zone
print(f"[RISK] Skipping — margin level critical: {summary['marginLevel']}%")
return False
return True
Part 4 — Executing Trades on Follower Accounts
With the risk engine in place, you can now execute the replicated trade on each follower account with /OrderSend. Two details make it production-safe:
clientOrderId: built from the master deal and the follower, so a retry after a network error can never open a second order.state: HTTP 200 does not mean filled. Onlystate == 10009is a done order; any other value is the broker's reason for refusing it.
# {master_position: {follower_name: {"ticket", "ratio", "master_volume", "sl", "tp"}}}
TICKET_MAP: dict = {}
# Latest master positions from the POSITIONS frames: {ticket: position}
MASTER_POSITIONS: dict = {}
def replicate_open(deal: dict, master_equity: float):
"""Replicate a master opening deal to every follower, risk pipeline first."""
for name, follower_id in FOLLOWERS.items():
try:
summary = get_account_summary(follower_id)
if not should_replicate(summary):
continue # safety gate failed — skip this follower
lots = calculate_follower_lots(deal["volume"], master_equity, summary["equity"])
r = api.get(f"{API}/OrderSend", params={
"id": follower_id,
"symbol": deal["symbol"], # map symbol names per broker, see below
"operation": "Buy" if deal["type"] == "BUY" else "Sell",
"volume": lots,
"comment": f"copy#{deal['position']}",
"clientOrderId": f"copy-{deal['deal']}-{name}",
}, timeout=75)
r.raise_for_status()
order = r.json()
if order.get("state") != 10009:
print(f"[{name}] broker refused the copy, retcode {order.get('state')}")
continue
# IMPORTANT: store master position → follower ticket.
# You need it to replicate closes and SL/TP changes later.
TICKET_MAP.setdefault(deal["position"], {})[name] = {
"ticket": order["ticket"], "ratio": lots / deal["volume"],
"master_volume": deal["volume"], "sl": 0.0, "tp": 0.0,
}
print(f"[{name}] copied open: {lots} lots → ticket {order['ticket']}")
except Exception as e:
print(f"[{name}] replication failed: {e}")
# The master's SL/TP may already be known from a POSITIONS frame
if deal["position"] in MASTER_POSITIONS:
sync_stops(MASTER_POSITIONS[deal["position"]])
MT4 followers: the same code works. /OrderSend, /OrderClose and /OrderModify take the same parameters on MT4 and MT5; on MT4 a refused order reports the MT4 error number in state (success is still 10009).
Part 5 — Handling Closes and Modifications
A complete copy trading platform must also replicate when the master closes or modifies a trade. Closes arrive as TRADE frames with entry: "OUT"; SL/TP changes arrive in POSITIONS frames.
Closing a Follower Trade
When the master closes position 294871023, you look it up in your ticket map and close the follower's corresponding ticket with /OrderClose. Without lots it closes the whole position; with lots it closes part of it:
def replicate_close(deal: dict):
"""Close (or partially close) follower positions when the master closes."""
links = TICKET_MAP.get(deal["position"], {})
for name, link in list(links.items()):
remaining = round(link["master_volume"] - deal["volume"], 2)
params = {"id": FOLLOWERS[name], "ticket": link["ticket"]}
if remaining > 0: # partial close on the master
params["lots"] = round(deal["volume"] * link["ratio"], 2)
try:
r = api.get(f"{API}/OrderClose", params=params, timeout=75)
r.raise_for_status()
result = r.json()
if result.get("state") != 10009:
print(f"[{name}] close refused, retcode {result.get('state')}")
continue
print(f"[{name}] closed follower ticket {link['ticket']}")
if remaining > 0:
link["master_volume"] = remaining
else:
del links[name]
except Exception as e:
print(f"[{name}] close failed: {e}")
Copying Stop-Loss and Take-Profit Changes
def sync_stops(position: dict):
"""Copy the master's sl/tp to every follower position that differs."""
for name, link in TICKET_MAP.get(position["ticket"], {}).items():
if (link["sl"], link["tp"]) == (position["sl"], position["tp"]):
continue
r = api.get(f"{API}/OrderModify", params={
"id": FOLLOWERS[name],
"ticket": link["ticket"],
"stoploss": position["sl"], # absolute prices; 0 = none
"takeprofit": position["tp"],
}, timeout=75)
result = r.json() if r.ok else {}
if result.get("state") == 10009:
link["sl"], link["tp"] = position["sl"], position["tp"]
print(f"[{name}] modified ticket {link['ticket']}: sl={position['sl']} tp={position['tp']}")
else:
print(f"[{name}] modify refused: {r.status_code} {result.get('state')}")
The Master Event Router
Bring all handlers together in one clean dispatcher:
def on_master_trade(deal: dict):
"""Route each master deal to the right handler."""
if not deal.get("symbol"):
return # defensive: TRADE frames are trade deals, always with a symbol
if deal["entry"] == "IN":
master_equity = get_account_summary(MASTER_ID)["equity"]
replicate_open(deal, master_equity)
elif deal["entry"] in ("OUT", "INOUT"):
replicate_close(deal)
def on_master_positions(positions: list):
"""Keep the latest master positions and copy any SL/TP change."""
MASTER_POSITIONS.clear()
MASTER_POSITIONS.update({p["ticket"]: p for p in positions})
for p in positions:
sync_stops(p)
# Start the WebSocket listener and keep the process alive
start_master_listener(MASTER_ID, on_master_trade, on_master_positions)
while True:
time.sleep(60)
Your copy trading engine is now fully operational. Every trade the master opens, closes, or modifies is replicated in near real time across every registered follower account — with proportional sizing and margin safety gates.
Part 6 — Monitoring Open Positions Across All Accounts
Your platform needs a live view of what is open across all accounts — for your dashboard, for your followers, and for your own risk monitoring. /OpenedOrders gives you that (open positions and pending orders, as order objects):
def get_all_open_positions() -> dict:
"""Fetch open positions for master and all followers."""
all_positions = {}
for name, account_id in {"master": MASTER_ID, **FOLLOWERS}.items():
r = api.get(f"{API}/OpenedOrders", params={"id": account_id}, timeout=75)
r.raise_for_status()
orders = r.json()
all_positions[name] = orders
for o in orders:
print(f" [{name}] {o['symbol']} type={o['orderType']} "
f"| {o['lots']} lots | P/L: {o['profit']:.2f}")
return all_positions
Poll this every 30–60 seconds to keep your dashboard live. Without polling, read the POSITIONS frames on each account's stream instead: they carry profit for every open position and arrive whenever the positions change.
Build vs. Buy: DIY API vs. White-Label
Many developers start by evaluating whether to build from scratch or use a white-label copy trading solution. Here is the honest comparison:
| Feature | White-Label Provider | MetaTrader API DIY Build |
|---|---|---|
| Time to first working demo | Days (theirs, not yours) | Under 2 hours with this guide |
| Monthly SaaS cost | €500–€3,000+/mo | MetaTrader API seats only ($12–$14 per connected account per month) |
| Revenue share to provider | 10–30% of subscription revenue | 0%: you keep everything |
| Full source code ownership | No | Yes |
| Custom risk rules per follower | Limited | Unlimited |
| Cross-broker copy (MT4 ↔ MT5) | Rare | Native |
| Own subscriber database | Locked in their CRM | Your database |
| Custom branding & domain | Partial | Complete |
| Regulatory compliance | Provider handles some | You own it |
| Scaling | Limited by the provider | Up to 50 seats per customer, more on request |
The white-label makes sense if you need to launch in 48 hours with no developers. If you are reading a technical guide like this, you are building something — and the DIY path gives you complete control of a product that is actually yours.
Scaling Your Copy Trading Platform
Once you have the core engine working, these are the real production concerns:
Multiple Master Accounts
You can run multiple master listeners simultaneously — each in its own background thread with its own WebSocket connection. Your server becomes a fan-out router: N masters → M followers each.
Account Persistence
Store every account id in a database (PostgreSQL, Redis). An account id never changes and the broker connection behind it is restored automatically, so a restart of your server only means reopening the WebSocket streams. Check /ConnectState (without id, it lists all your accounts) at startup and call /Reconnect?id= for any account that reports DISCONNECTED. This makes restarts invisible to your users.
Symbol Name Mapping
Different brokers use different symbol names (EURUSD vs EURUSD.m vs EURUSDpro). Build a configurable mapping table per follower account at registration time. Use /SymbolList to query what symbols are available on any connected account before executing:
def get_symbol_list(account_id: str) -> list:
r = api.get(f"{API}/SymbolList", params={"id": account_id}, timeout=75)
r.raise_for_status()
return r.json()
Follower Onboarding Flow
When a new subscriber signs up to your platform:
- Collect their MT5 login, main password, and exact server name
- Call
POST /v1/seats(theconnect()function above) to connect the account - Store the returned account id in your database
- Call
/AccountSummaryto verify the connection and record their starting balance - Show them their account stats on your dashboard
The flow is a handful of API calls; most of the time is the broker login, which usually completes within one to two minutes.
Trade History and Performance Stats
Use /OrderHistory to pull closed trades for any account and compute performance metrics — win rate, profit factor, max drawdown — to display on leaderboards or subscriber-facing profile pages:
def get_trade_history(account_id: str, from_date: str, to_date: str) -> list:
"""Closed trades in the range. /OrderHistory answers {"orders": [...], ...}."""
r = api.get(f"{API}/OrderHistory", params={
"id": account_id,
"from": from_date, # ISO-8601 or unix seconds, UTC, e.g. "2026-01-01T00:00:00Z"
"to": to_date, # e.g. "2026-06-09T23:59:59Z"
}, timeout=75)
r.raise_for_status()
return r.json()["orders"]
What to Build Next
Once your copy engine is running, here is a natural roadmap:
Subscriber web dashboard — live open positions, equity chart, copy status for each follower
Payment integration — gate API access to paid subscribers via Stripe webhooks
Telegram / Discord bot — notify subscribers when a master trade opens
Performance leaderboard — rank master traders by return, drawdown, and consistency
Risk profile per follower — let each subscriber choose conservative / moderate / aggressive lot sizing
Multi-master support — let subscribers follow multiple master accounts simultaneously
For the broker and CRM integration side of scaling this platform, see:
How Brokers Automate Account Management with MetaTrader API →
For prop firm specific use cases — where you need to enforce drawdown rules on follower accounts in real time — see:
Build Prop Firm Rule Enforcement on MetaTrader Accounts →
Frequently asked questions
Can I build a copy trading platform without MQL5?
Yes. Everything in this guide runs in standard Python over HTTP and WebSocket. You do not need to write, compile, or install a single line of MQL5. MetaTrader API handles the connection to the broker on its side; there is nothing for you to install.
How does copy trading work technically with the MT5 REST API?
Your server connects the master and follower accounts with POST /v1/seats (or /ConnectEx), then opens the master's WebSocket at wss://api.metatraderapi.net/v1/stream?id=<account id>. TRADE frames arrive for every executed deal without any subscription. On each opening deal, your risk engine calls /AccountSummary on the follower, calculates proportional lots, then calls /OrderSend with a clientOrderId and checks for state == 10009. Full endpoint details are in the API documentation.
Can followers be at different brokers?
Yes. Each follower connects their own MT4 or MT5 account independently. The broker is irrelevant — only the login, password, and server name matter. Build a symbol-name mapping table if followers have different broker symbol naming conventions.
What is the latency from master trade to follower execution?
The TRADE frame on the master's /v1/stream WebSocket arrives shortly after the deal is executed: new deals are picked up about once per second, so allow roughly one to two seconds from the master's fill until your follower orders go out, plus each follower broker's own execution time. Sending the follower orders in parallel keeps that from growing with the number of followers. MetaTrader API runs its API servers in the EU (Finland and Germany) — see Low-Latency MetaTrader Infrastructure → for infrastructure design details.
Does this work for MT4 as well as MT5?
Yes. The same endpoints (/OrderSend, /OrderClose, /OrderModify) and the same base URL work for MT4 and MT5, so the architecture is identical. On MT4 a refused order reports the MT4 error number in state; success is still 10009.
Related Reading
How to Become a Forex Signal Provider Without MQL5 — Start simpler: broadcast signals before building full copy execution
MT5 API for Prop Trading Firms: Challenge Automation — Apply copy trading patterns to prop firm onboarding
Build a Reliable Forex & CFD Scalping Bot with MetaTrader API — Add automated strategy execution on top of your copy layer
How to Build an AI Forex Trading Bot — Route AI signals through the same WebSocket + execution pipeline
MetaTrader API CRM Integration for Brokers — Sync your subscriber data into a CRM
Forex Broker API Integration: A Complete Developer Guide — The full broker-level integration picture
Low-Latency MetaTrader Infrastructure for Brokers — Optimize the infrastructure your copy engine runs on
External resources:
- ESMA (European Securities and Markets Authority) — regulatory context for European platforms
- Python websocket-client library — The WebSocket library used in all code examples above
- MetaTrader 5 platform overview — Official MT5 documentation

Create Your Account Today
The complete copy trading engine in this guide is under 200 lines of Python. You can have a working demo connecting a master and follower account in under two hours. Each connected account is a paid seat ($14 per month); there is no free trial, so test with broker demo accounts.
→ Create account at app.metatraderapi.net
→ Read the full API documentation at app.metatraderapi.net/docs
→ Browse all developer guides on the MetaTrader API Blog
Your copy trading platform. Your subscribers. Your revenue.
MetaTrader®, MT4® and MT5® are registered trademarks of MetaQuotes Ltd. MetaTrader API is an independent service and is not affiliated with, authorized by, or endorsed by MetaQuotes.
Ready to integrate the MetaTrader API?
REST, WebSocket and gRPC for MT4 and MT5. Pay per connected account.
Create account