Place Your First MetaTrader 5 Order From Python: No Terminal, No VPS
You can place a MetaTrader 5 order with a few HTTPS requests from any language, on any machine. This walkthrough takes a demo account from saved login to a closed trade with the TS MT5 API, then shows the one retry rule that keeps a bad network moment from doubling your position.
In This Article
- Why a Hosted API
- What You Need
- Step 1: Add Your MT5 Login
- Step 2: Open a Session
- Step 3: Find the Symbol and Read a Price
- Step 4: Place the Order
- Step 5: Check It, Then Close It
- When You Do Not Get a Clear Answer
- Watch It Live With the Stream
- Mistakes That Cost Time
- The Trial, Your Limits and Your Password
- Frequently Asked Questions
1. Why a Hosted API
The official MetaTrader5 package for Python works by talking to a MetaTrader 5 terminal. The terminal has to be installed and running next to your script, and the package is built for Windows. That is fine on a desktop that stays on. It is awkward on a Mac, on Linux, in a container, or on a small cloud function, and it usually ends with renting a Windows VPS just to keep a terminal open.
The TS MT5 API takes the terminal out of your setup. It is a hosted REST and WebSocket API for MetaTrader 5 accounts: you get a base URL and an API key, save your MT5 login with one call, and then read quotes, positions and history and place orders over plain HTTPS and JSON. There is nothing to install and no server to keep running.
2. What You Need
- A TS MT5 API key. Log in, open the TS MT5 API page and start your free 7-day trial. The key is shown once, so copy it somewhere safe.
- Your base URL and two account ids. They are listed under “Your access” on the same page. This article writes the base URL as
API_HOST. - A demo MT5 login. The login number, password and the server name exactly as your MT5 app shows it at login (for example
ICMarketsSC-Demo). - Python 3 with the
requestslibrary, or just curl.
t1a2b3c4d5e6f7a8b-1. It is a
slot that holds one MT5 login, and it stays the same if you ever get a new key.
3. Step 1: Add Your MT5 Login
You do this once per account. The call saves your login under one of your account ids. Send the login as a string, and the server as a name, not an address.
curl -s -X PUT https://API_HOST/accounts/ACCOUNT_ID \
-H 'X-API-Key: YOUR_KEY' -H 'Content-Type: application/json' \
-d '{"login": "12345678", "password": "your-mt5-password", "server": "Broker-Server"}'
The password is stored encrypted and is never returned by the API. Saving does not check the
login: a wrong login, password or server only shows up in the next step. If the reply says
"server_known": false, the server name is not recognised yet, so check the spelling.
4. Step 2: Open a Session
POST /connect opens a session and returns a session token. The token is what you use for everything
from here on. Two things to know before you write the call:
- Allow it time. Connecting can take tens of seconds, so use a client timeout of at least 60 seconds.
- Check
session_ready. A successful connect does not by itself mean orders will be accepted.session_ready.trademust be true. Right after connecting it can take a few seconds.
Here is steps 1 and 2 together in Python (the rest of the article continues this script):
import os
import uuid
from urllib.parse import quote
import requests
BASE = "https://API_HOST" # your base URL
KEY = os.environ["TSMT5_KEY"] # your API key, kept in an environment variable
ACCT = "ACCOUNT_ID" # one of your two account ids
# Save the MT5 login once. The password is stored encrypted and never returned.
r = requests.put(
f"{BASE}/accounts/{ACCT}", headers={"X-API-Key": KEY}, timeout=30,
json={"login": "12345678", "password": os.environ["MT5_PASSWORD"], "server": "Broker-Server"},
)
r.raise_for_status()
if r.json().get("server_known") is False:
raise SystemExit("That server name is not recognised: check the spelling")
# Open a session. Connecting can take tens of seconds, so allow 60 s.
c = requests.post(
f"{BASE}/connect", headers={"X-API-Key": KEY}, timeout=60,
json={"account_id": ACCT, "magic": 1001, "label": "first-order"},
)
c.raise_for_status() # on 401, 422, 429 or 503 read the error: do not loop
session = c.json()
H = {"Authorization": f"Bearer {session['token']}"}
if not session.get("session_ready", {}).get("trade", True):
raise SystemExit("Connected, but orders are not accepted yet: wait and check again")
Open one session and reuse its token. A token ends after 15 minutes without a request, or after 12 hours. If a
later call answers 401 no_session, call /connect once and carry on. Never connect for
every request: /connect shares a small request budget, and a failing login retried in a loop can get
your MT5 account locked by your broker.
5. Step 3: Find the Symbol and Read a Price
Brokers name the same instrument differently: EURUSD at one, EURUSD.m or
EURUSD_l at another, GOLD instead of XAUUSD at a third. Always ask for the
broker’s real name first, and use exactly what comes back. The contract spec also tells you the lot sizes
your broker allows, which you need in the next step.
# Your broker may call the instrument EURUSD, EURUSD.m, EURUSD_l ... ask for its real name.
res = requests.get(f"{BASE}/symbols/resolve/EURUSD", headers=H, timeout=30)
res.raise_for_status() # a 404 lists the names your broker uses instead
symbol = res.json()["name"]
enc = quote(symbol, safe="") # names can contain # or +, so encode them in URLs
spec = requests.get(f"{BASE}/symbols/{enc}", headers=H, timeout=30).json()
tick = requests.get(f"{BASE}/quote/{enc}", headers=H, timeout=30).json()
print(symbol, "bid", tick["bid"], "ask", tick["ask"])
print("min lots", spec["min_lots"], "lot step", spec["lot_step"])
The quote carries bid, ask, the broker’s time and
age_seconds. If the market is closed, the last price can be old even when
age_seconds is small, so look at time before you price anything from it.
6. Step 4: Place the Order
The order call has four rules worth building in from the start:
- Send an
Idempotency-Key. Make it unique for each intended order. It is what makes a retry safe (see the section on unclear answers). - Size from the spec.
lotsis sent exactly as you give it, never rounded or raised. Use a value betweenmin_lotsandmax_lotsthat is a multiple oflot_step. - Set your own
deviation. It is the most slippage you accept, in points. The default is large on some symbols. - Allow 90 seconds. A market order usually takes a few seconds but can take up to about a minute.
operation is a number: 0 BUY, 1 SELL, 2 BUY_LIMIT, 3 SELL_LIMIT, 4 BUY_STOP, 5 SELL_STOP,
6 BUY_STOP_LIMIT, 7 SELL_STOP_LIMIT. Market orders (0 and 1) need no price. This script takes a snapshot of your
open positions first, which pays off in the next sections.
def open_positions():
"""Open positions by ticket. Refuses to answer while the list may be incomplete."""
r = requests.get(f"{BASE}/positions", headers=H, timeout=30)
r.raise_for_status()
data = r.json()
if not data.get("book_read"):
raise RuntimeError("position list may be incomplete: wait a few seconds and read again")
return {p["ticket"]: p for p in data["positions"]}
before = open_positions()
ask = tick["ask"]
order = {
"symbol": symbol,
"operation": 0, # 0 = BUY, 1 = SELL
"lots": spec["min_lots"], # the smallest size your broker allows
"sl": round(ask * 0.995, 5), # stop-loss below a BUY
"tp": round(ask * 1.005, 5), # take-profit above a BUY
"deviation": 50, # the most slippage you accept, in points
}
key = str(uuid.uuid4()) # one key per intended order
o = requests.post(f"{BASE}/orders", headers={**H, "Idempotency-Key": key}, json=order, timeout=90)
if o.status_code == 201:
ticket = o.json()["position"]["ticket"]
print("opened position", ticket)
elif o.status_code == 409 and o.json().get("error") == "not_confirmed":
print("Outcome unknown: do NOT resend. See the section on unclear answers.")
else:
print("Refused:", o.status_code, o.text)
A 201 for a market order is only sent once the new position shows on your account, so the reply
carries position and position.ticket is the handle you use to change or close it.
422 rejected with retcode 10016. The 0.5% used above is far enough on
most forex pairs. If you trade something with a wide stop distance, move the levels out.
7. Step 5: Check It, Then Close It
GET /positions lists what is open. Notice the book_read flag in the reply, and the
open_positions() helper above that refuses to answer when it is not true: until the account’s book
has been read, the list can be incomplete, so an empty list does not prove you are flat.
To close, call POST /positions/{ticket}/close. With no body it closes the whole position; send
{"lots": 0.05} for part of it. Use an Idempotency-Key here too, because a blind
retry of a partial close would close a second slice.
print(open_positions().get(ticket))
cl = requests.post(
f"{BASE}/positions/{ticket}/close",
headers={**H, "Idempotency-Key": str(uuid.uuid4())}, timeout=90,
)
print(cl.status_code, cl.json()) # a full close reports close_price, profit, commission and swap
# The realised result of the trade:
hist = requests.get(f"{BASE}/history/positions", headers=H, params={"position": ticket}, timeout=30)
print(hist.json())
8. When You Do Not Get a Clear Answer
This is the rule that matters most once real money is involved. Treat an order or a close as possibly
done whenever you did not get a clear answer: a 409 not_confirmed, any 5xx, or a timeout or
dropped connection before the reply. Do not resend it as a new order.
- Repeat the exact same request with the same
Idempotency-Key. Within 10 minutes and on the same session token, you get the original result back, oridempotency_in_progress(wait, then repeat), ornot_confirmedagain. It cannot place a second order. - If that is not possible or still unclear, read your positions. Trust an empty list only when
book_readis true, and look for a position on that symbol and side that was not there before you sent the order. - If it is there, use its ticket. If it is not, and
book_readis true, nothing filled and it is safe to send again with a new key.
Here is the order call from Step 4 with that rule built in. It replaces the plain POST there:
import time
def send_order():
return requests.post(f"{BASE}/orders", headers={**H, "Idempotency-Key": key}, json=order, timeout=90)
def still_running(resp):
detail = resp.json().get("detail") if resp.status_code == 409 else None
return isinstance(detail, dict) and detail.get("error") == "idempotency_in_progress"
def place_with_retry():
"""Same key + same body: the original result, 'still running', or 'not confirmed' again."""
for _ in range(3):
try:
resp = send_order()
except requests.exceptions.RequestException:
continue # reply lost: repeating the SAME request cannot double the order
if still_running(resp):
time.sleep(int(resp.headers.get("Retry-After", 3)))
continue
return resp
return None
o = place_with_retry()
if o is not None and o.status_code == 201:
ticket = o.json()["position"]["ticket"]
else:
after = open_positions() # raises while book_read is false: wait, then ask again
new = [p for t, p in after.items() if t not in before and p["symbol"] == symbol and p["side"] == "Buy"]
if new:
ticket = new[0]["ticket"] # it did fill: use this ticket
else:
print("Nothing opened. Send the order again with a NEW key.")
opened_at is the broker’s own clock, which can be hours away from yours.
Comparing tickets before and after avoids the clock entirely.
A 429 is the opposite case: nothing was sent, so waiting for Retry-After and trying again is correct.
9. Watch It Live With the Stream
Polling is fine for a first script. For a strategy, open a WebSocket and let the updates come to you. Send the
token in the first message, within 10 seconds of connecting, and never in the URL. The stream
sends prices (quote) and trade events such as position_opened,
position_changed and position_closed. The example uses the websockets library.
import asyncio
import json
import websockets
async def watch(token, symbol):
async with websockets.connect("wss://API_HOST/stream") as ws:
# The first message must arrive within 10 seconds, and carries the token.
await ws.send(json.dumps({"token": token, "symbols": [symbol], "interval_ms": 500}))
async for raw in ws:
event = json.loads(raw)
if event["type"] in ("quote", "position_opened", "position_changed", "position_closed"):
print(event)
asyncio.run(watch(session["token"], symbol))
The stream reports changes, not state, and positions that were already open when you subscribed are not
announced. Read GET /positions first, open the stream, then apply events on top, matched by ticket.
Read the positions again after a lagged or reconnected event. If your client cannot use
WebSockets, the same stream is available as server-sent events.
10. Mistakes That Cost Time
| What you see | Likely cause | Fix |
|---|---|---|
| Your client times out on connect | Connecting can take tens of seconds | Timeout of at least 60 s for connect, and 90 s for orders and closes |
503 broker_closed_session on connect |
Wrong login, password or server | Save again with PUT, then connect with "fresh": true. Never loop: repeated wrong passwords can lock the account |
422 unknown_server |
The server name is not recognised yet | Check it against your MT5 login window. If it is right, contact us with your broker and the exact server name |
409 not_tradable |
session_ready.trade is false |
Wait a few minutes and check again. Each refused order still uses one unit of your trial allowance, so do not retry in a loop |
422 rejected, retcode 10014 |
Lot size is too small or off the lot_step grid |
Read min_lots, max_lots and lot_step from GET /symbols/{name} |
422 rejected, retcode 10016 |
Stop-loss or take-profit too close to the price | Move the levels further from the price |
401 no_session |
The session token expired | Call /connect once and continue. If connect answers 401 key_expired, stop: your key no longer works |
429 rate_limited |
You were throttled. Nothing was sent | Wait for Retry-After, then retry |
| The position list is empty but you just opened a trade | book_read is false: the list may be incomplete |
Wait a few seconds and read again. Trust an empty list only when book_read is true |
404 on /symbols/resolve |
Your broker names the instrument differently | The reply lists candidates (for example GOLD). Pick one and use it exactly |
magic does not separate strategies. It labels your session and is not sent to your
broker. Every session on the same MT5 account sees, and can change or close, the same positions, including trades
you placed by hand in MT5. To keep two strategies apart, give each its own MT5 account.
11. The Trial, Your Limits and Your Password
The TS MT5 API is in beta, with a free trial open to every logged-in user:
- One free 7-day trial per user, with up to 300 new orders. Demo and live accounts both work.
- Every
POST /ordersthat passes validation counts as one, even if your broker then refuses it. Closing, cancelling and changing stops never count. - Closing is never capped. After the trial ends you have 24 hours to close or cancel anything still open. Nothing is closed for you, and a pending order can still fill after your key stops.
- Rate limits. New orders are limited to 2 per second per account (a burst of 5), and reads to 20 per second.
- Your password. Remove a saved login any time with
DELETE /accounts/{id}, or use the “Delete my saved broker passwords” button on the TS MT5 API page.
Every response to a request made with your key carries X-Trial-Orders-Left and
X-Trial-Seconds-Left, so your script can watch its own allowance. Paid plans are not listed while the
product is in beta.
The short version
- Save the login once, connect once, reuse the token.
- Check
session_ready.tradebefore ordering andbook_readbefore trusting a list. - One
Idempotency-Keyper intended order, and never resend blindly after an unclear answer. - Demo first. On a live account every order is real.
12. Frequently Asked Questions
Do I need to install MetaTrader 5 or rent a Windows VPS?
No. The TS MT5 API is a hosted service. Your code only needs an HTTP client, on any operating system. You add your MT5 login with one call and the service handles the connection to your broker.
Can I use it on a live account?
Yes. Demo and live accounts both work. On a live account every order is real, so build and test on a demo account first and start live with small sizes.
Is my MT5 password safe?
It is stored encrypted and the API never returns it. You can remove it at any time with DELETE /accounts/{id}, or with the Delete my saved broker passwords button on the TS MT5 API page. Keep your API key on your server: never put it in a browser, a mobile app or a public repository.
My order request timed out. Is it safe to send it again?
Only the exact same request with the same Idempotency-Key, within 10 minutes and on the same session. That returns the original result and cannot place a second order. If you cannot do that, read your positions (trusting an empty list only when book_read is true) before deciding. A new order under a new key can double your position.
What counts toward the 300 new orders in the trial?
Every POST /orders that passes authentication, validation, rate limits and the trial checks counts as one, even if your broker then refuses it. Closing positions, cancelling orders and changing stop-loss or take-profit levels never count.
Does it replace an Expert Advisor?
It is a different approach. An Expert Advisor runs inside a terminal that you host. The API lets your own code, running anywhere, read data and send orders over HTTPS.
Can I download the code or run it on my own server?
No. The TS MT5 API is provided as hosted access only: a base URL, an API key and the developer guide.
This is a technical walkthrough, not investment advice. Trading involves risk, and orders on a live account are real.
Try the TS MT5 API Free for 7 Days
One free trial per account, up to 300 new orders • Demo and live accounts • Hosted, nothing to install
Start the Free TrialRead the developer guide • Need help? Chat with us on WhatsApp