API MetaTrader 5 Tutorial New

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.

TS
TradeSteady Algo
Published: 10 min read

In This Article

  1. Why a Hosted API
  2. What You Need
  3. Step 1: Add Your MT5 Login
  4. Step 2: Open a Session
  5. Step 3: Find the Symbol and Read a Price
  6. Step 4: Place the Order
  7. Step 5: Check It, Then Close It
  8. When You Do Not Get a Clear Answer
  9. Watch It Live With the Stream
  10. Mistakes That Cost Time
  11. The Trial, Your Limits and Your Password
  12. 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.

Use a demo account for this walkthrough. Demo and live accounts both work, but on a live account every order is real. Get the code right on a demo first, then go live with small sizes.

2. What You Need

An account id is not your MT5 login. An id looks like t1a2b3c4d5e6f7a8b-1. It is a slot that holds one MT5 login, and it stays the same if you ever get a new key.
Keep the key on your server. Never put it in a browser, a mobile app or a public repository. The examples read it from an environment variable. If it leaks, revoke it on the TS MT5 API page and get a new one.

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:

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:

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.

Stops that are too close are refused. A stop-loss or take-profit closer than the symbol’s minimum stop distance answers 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.

  1. 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, or idempotency_in_progress (wait, then repeat), or not_confirmed again. It cannot place a second order.
  2. If that is not possible or still unclear, read your positions. Trust an empty list only when book_read is true, and look for a position on that symbol and side that was not there before you sent the order.
  3. If it is there, use its ticket. If it is not, and book_read is 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.")
Why compare against a snapshot, not a time? Until your broker’s time zone is known, a position’s 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 seeLikely causeFix
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:

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.

Hosted access only. The TS MT5 API is provided as a hosted service: a base URL, an API key and the developer guide. Nothing to download or keep updated.

The short version

  • Save the login once, connect once, reuse the token.
  • Check session_ready.trade before ordering and book_read before trusting a list.
  • One Idempotency-Key per 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 Trial

Read the developer guide  •  Need help? Chat with us on WhatsApp

Share this article:

Related

Disclaimer · Terms · Privacy · FAQ