TS MT5 API - developer guide

A hosted REST and WebSocket API for MetaTrader 5 accounts. Plain HTTPS and JSON, from any language. Demo and live accounts both work - on a live account every order is real.

Get startedQuickstartKeys and sessionsYour accountsCan it trade?Symbols and pricesPositions and historyPlacing ordersChanging and closingIdempotencyUnknown outcomesStreamingErrorsTrial and limitsTimesChecklist

1. Get started

  1. Log in and open the TS MT5 API page. Start your free 7-day trial (one per user).
  2. Copy three things: your API key (shown only once - it cannot be shown again), your base URL, and your two account ids.
  3. An account id looks like t1a2b3c4d5e6f7a8b-1. It is not your MT5 login number: it is a slot that holds one MT5 login. Your ids stay the same if you get a new key, and they are listed under "Your access" on that page.
  4. Lost the key? Revoke it on that page and get a new one. Revoking ends every session the key opened at once, so stop your strategy first. A new trial key keeps your trial's end date and what is left of its 300-order allowance, and it can use the MT5 logins you already added.

Examples below use https://API_HOST; when you are signed in, this page fills in your base URL. Fields not described here are diagnostic and may change or disappear - do not depend on them.

2. Quickstart

# 1. Add your MT5 login under one of your account ids (once). Send login as a string.
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"}'

# 2. Open a session (answers 201). Check session_ready before trading (section 5).
curl -s -X POST https://API_HOST/connect \
  -H 'X-API-Key: YOUR_KEY' -H 'Content-Type: application/json' \
  -d '{"account_id": "ACCOUNT_ID", "magic": 1001, "label": "my-strategy"}'
# -> {"token": "...", "account_id": "ACCOUNT_ID", "expires_in": 900, "session_ready": {...}, "trial": {...}, ...}

# 3. Use the session token for trading, market data and account calls
export TOK=SESSION_TOKEN
curl -s https://API_HOST/symbols/resolve/XAUUSD -H "Authorization: Bearer $TOK"   # -> the broker's name, e.g. XAUUSD_l
curl -s https://API_HOST/quote/XAUUSD_l -H "Authorization: Bearer $TOK"
curl -s -X POST https://API_HOST/orders -H "Authorization: Bearer $TOK" \
  -H 'Idempotency-Key: order-0001' -H 'Content-Type: application/json' \
  -d '{"symbol": "XAUUSD_l", "operation": 0, "lots": 0.01, "sl": 4400.0, "tp": 4600.0}'
curl -s https://API_HOST/positions -H "Authorization: Bearer $TOK"

The same in Python (any HTTP client works; this uses requests):

import uuid, requests
BASE, KEY, ACCT = "https://API_HOST", "YOUR_KEY", "ACCOUNT_ID"

c = requests.post(f"{BASE}/connect", headers={"X-API-Key": KEY},
                  json={"account_id": ACCT, "magic": 1001}, timeout=60)
c.raise_for_status()                             # 401/422/429/503: see section 13 - do not loop
s = c.json()
H = {"Authorization": f"Bearer {s['token']}"}
if not s.get("session_ready", {}).get("trade", True):
    raise SystemExit("connected, but orders are not accepted yet - see section 5")

res = requests.get(f"{BASE}/symbols/resolve/XAUUSD", headers=H, timeout=30)
res.raise_for_status()                           # 404 lists candidates: pick your broker's name
sym = res.json()["name"]
r = requests.post(f"{BASE}/orders", timeout=90,
                  headers={**H, "Idempotency-Key": str(uuid.uuid4())},
                  json={"symbol": sym, "operation": 0, "lots": 0.01})
if r.status_code == 201:
    ticket = r.json()["position"]["ticket"]      # use this ticket to modify or close
elif r.status_code == 409 and r.json().get("error") == "not_confirmed":
    pass                                         # do NOT resend - see section 11

3. Keys and sessions

CredentialHow to send itUsed for
API keyX-API-Key headerPUT, GET, DELETE /accounts and POST /connect
Session tokenAuthorization: Bearer <token> (or X-Session-Token)Every other endpoint. Two exceptions: GET /health needs nothing, and the WebSocket /stream takes the token in its first message.

POST /connect

Body: account_id (one of yours), magic (required, any whole number, 0 allowed) and optional label. Optional "fresh": true forces a new login to your broker (see below), and optional "otp" carries a one-time password for brokers that ask for one. It answers 201 with token, account_id, magic, expires_in (idle seconds), reused, capabilities, investor (true = read-only, every write answers 403 investor_mode), session_ready and trial. Connecting can take tens of seconds: use a client timeout of at least 60 s.

magic labels your session - it does not separate strategies. It is not sent to your broker, and orders reach the broker with no magic number and no comment. Every session on the same MT5 account sees and can change or close the same positions and orders, including trades you place by hand in MT5. To keep two strategies apart, give each its own MT5 account. Positions that other software on the account opened under its own non-zero magic number are not shown to you, and touching one answers 403 not_owned - unless your magic is the same as theirs, in which case you share them. Pick a magic no EA on the account uses, or 0.

Token lifetime

Keep your API key on your server. Never put it in a browser, a mobile app or a public repository. If it leaks, revoke it on the TS MT5 API page.

4. Your accounts

PUT /accounts/{account_id}API key. Body: login (a string, e.g. "12345678"), password, server - the server name exactly as your MT5 app shows it at login (e.g. ICMarketsSC-Demo), not an address. Optional role: "master" (default, can trade) or "investor" (save the investor password; read-only). 201 new, 200 replaced. The password is stored encrypted and never returned.
GET /accountsAPI key. Your saved accounts - metadata only (account_id, login, server, role, created, updated).
DELETE /accounts/{account_id}API key. Permanently deletes the saved credential. Sessions already open keep running until they end; call POST /disconnect to stop them now. 404 if nothing is saved.

5. Can the session trade yet?

A successful connect does not by itself mean orders will be accepted. Read session_ready in the connect reply (also on GET /connection, together with connected):

Right after connecting this can take a few seconds. Some brokers need a one-time setup on our side before a newly added account works fully. Until then reads can be incomplete too - check read and book_read (section 7) before you treat the account as flat. Once the setup is done, an open session does not pick it up: connect again with "fresh": true (your other sessions on that account end, so connect them again too). If trade stays false for more than a few minutes, email us your account id. Each refused order still uses one unit of your new-order allowance, so never retry in a loop.

trade: true while read is false means trading is not yet proven: wait for read: true. trade: true also does not override a read-only account - see investor in the connect reply.

6. Symbols and prices

Symbol names differ by broker

Always trade the name returned by GET /symbols/resolve/{base} (e.g. XAUUSD -> XAUUSD_l). XAUUSD_l in these examples is one broker's name. If resolve answers 404, its candidates list shows what your broker calls the instrument (for example GOLD) - pick one and use it exactly. Order, quote and stream calls also map a base name for you (POST /orders returns requested_symbol, GET /quote returns requested), but the ?symbol= filter on /orders and /history needs the broker's exact name.

URL-encode symbol names in paths and queries: names can contain #, + or . (#AAPL becomes %23AAPL; XAUUSD+ in a query becomes XAUUSD%2B).

GET /symbolsSymbol names on the account. complete: false means the list arrived incomplete - do not treat a missing name as absent yet. The list is cached after the first read; add ?refresh=1 to read it again. ?details=1 returns every symbol's spec in one large, nested response with different field names - use GET /symbols/{name} for sizing.
GET /symbols/resolve/{base}The broker's real name for a base symbol, or 404 with candidates.
GET /symbols/{name}Contract spec, including digits, min_lots, max_lots, lot_step, tradable, trade_mode (CloseOnly and Disabled refuse new positions; LongOnly/ShortOnly allow one side), max_lots (null when the broker sets no maximum) and permitted order types. If defaulted is not empty, or spec_placeholder is true, those values are generic defaults, not your broker's - do not size orders from them. An unknown name answers 501, not 404: check names with resolve first.
GET /symbols/{name}/session-stateis_trade_session and is_quote_session right now. Check it before ordering; a 501 here means "unknown", not "open". GET /symbols/{name}/sessions gives the weekly quote and trade hours (server time).
GET /quote/{name}symbol, bid, ask, time, age_seconds, source. The first request for a symbol can take a few seconds while its feed starts. ?max_age_ms= refuses a price older than that with 503.
POST /quotes{"symbols": ["EURUSD", "XAUUSD"]} (1-200) -> {"quotes": {...}, "missing": [...]}, keyed by the names you sent. A symbol with no price is listed in missing, never returned as 0.
GET /bars/{symbol}?timeframe= M1, M2, M3, M4, M5, M6, M10, M12, M15, M20, M30, H1, H2, H3, H4, H6, H8, H12, D1, W1, MN1 (default M1), then either count= (1-5000, the last bars ending at to, default now) or from= with optional to=, in broker server time epoch seconds. Reply: {"symbol", "timeframe", "minutes", "bars": [{"time", "open", "high", "low", "close", "tick_volume", "volume", ...}]}. Bid prices; the last bar may still be forming. A range longer than 5000 bars returns its most recent 5000, and history depth is limited (roughly a year of M1): check the first bar's time rather than assuming from was honoured.
GET /margin/required?symbol=&lots=&side=&price= (side buy, sell, buy_limit ... or 0-7). Margin in your deposit currency. Pass price unless you hold that symbol. If it cannot be worked out, margin is null with a reason - still HTTP 200, so check for null.
GET /account, GET /account/detailsBalance, equity, margin, free margin, margin level; details add name, leverage, hedging/netting, currency, server and serverTimeZone. /account can answer 501 when the balance has not arrived (usually right after connecting) - back off and read again. If profit_unknown is true, equity and free margin leave out one or more positions: do not size from them.

For a symbol you hold, when no tick arrives /quote can return the position's mark price: source is position_mark, bid equals ask (not a tradable spread) and there may be no time. age_seconds and max_age_ms measure when we received a price, not when it traded.

Closed markets. time is your broker's server time of the last price change; age_seconds is how long ago we received it. On a closed market a quote can have a small age_seconds but an old time - check time before pricing from it. A /quote that answers 501 after several seconds usually means no price arrived because the market is closed or quiet.

7. Positions, orders and history

GET /positions{"positions": [...], "book_read": true|false}. Each position: ticket, symbol, side (Buy/Sell), lots, open_price, price_current, profit, sl, tp (0 = none; null = could not be read, which is not the same as none - do not send it back as a level), opened_at (epoch seconds, or null; see section 15), pnl_at (when the price and profit last updated). profit is null when it cannot be worked out right now. Optional ?symbol=.
GET /positions/{ticket}One position; 404 when it is not in this session's list. That list has the same book_read caveat, so a 404 proves "closed" only when GET /positions shows book_read: true.
GET /orders{"orders": [...]}: every working (pending) order on the account, including ones placed by hand - once the account's book has been read (book_read on GET /positions). Before that it may list only orders this session placed, so an empty list is not proof of none. Each has ticket, symbol, operation, type, lots, price, stop_limit_price, sl, tp, expiration.
GET /historyExecuted deals, oldest first: {"deals": [...], "next_cursor": ...}. Window ?days= (default 1) or ?from=&to= (broker server time). Filters &symbol= (exact broker name) and &position= (a position ticket). Each deal has ticket, position_id, symbol, entry, lots, price, profit, commission, swap, fee, net, reason_name, time. Page with limit (1-5000) and pass next_cursor back as cursor until it is null. Deposits and other balance operations are listed too: leave them out (by type) when summing trading results. Positions opened long before the window (about 30 days for deals, 90 for closed positions) may not appear.
GET /history/positionsClosed positions rebuilt from their deals, with open/close price and time, net and the close reason (SL, TP, ...). Same window, filters and paging.
GET /history/ordersPending orders that left the book: filled, cancelled, expired, rejected.
GET /ticks/{symbol}, GET /market-watch/{symbol}Tick history (?from=&to=, server time) and the day's high, low, open, previous close and change.
book_read matters. When it is not true, the list may be incomplete: an empty list does not mean you are flat. Wait a few seconds and read again; if it stays false, connect with "fresh": true. Never conclude "nothing open" - or resend an order - from a list with book_read: false.

History may answer 501 or 503 when your broker does not send it in time; you never get a partial history. Retry once after a few seconds, with a shorter range if you asked for a long one.

8. Placing orders

POST /orders        (session token, Idempotency-Key recommended)
{"symbol": "XAUUSD_l", "operation": 0, "lots": 0.01, "sl": 4400.0, "tp": 4600.0, "deviation": 50}
0 BUY1 SELL2 BUY_LIMIT3 SELL_LIMIT
4 BUY_STOP5 SELL_STOP6 BUY_STOP_LIMIT7 SELL_STOP_LIMIT

The reply

9. Changing and closing

PATCH /positions/{ticket}{"sl": ..., "tp": ...}. Omit a field to keep it; send null (or 0) to clear it. The call waits a few seconds for the new levels to show; levels_verified: false means your broker accepted the change but it was not visible yet. On 409 not_confirmed the stop may not be set: read the position and send it again if needed.
POST /positions/{ticket}/closeBody optional: omit it to close the whole position, or {"lots": 0.05} for part. Optional deviation (points; default 1000 - set your own). Waits until the close is seen. A full close reports close_price, lots, profit, commission and swap when your broker reports them; a partial close reports closed_lots and remaining_lots. Read the realised result from GET /history/positions. Supports Idempotency-Key - always use one on partial closes, or a blind retry closes a second slice.
POST /positions/closeClose several positions at once: {"tickets": [111, 222]} -> {"closed": [...], "failed": [...]}. A ticket that is not yours or already closed is listed in failed and the rest are still closed. If any position is still open afterwards the call answers 409 not_confirmed for the whole basket: re-read GET /positions (with book_read: true) for every ticket you sent and close again only what is still listed. Supports Idempotency-Key. The clean way to flatten.
PATCH /orders/{ticket}Move a working order: price, stop_limit_price, sl, tp, expiration_type/expiration. Send at least one field (an empty body is 422 nothing_to_modify). Works only for orders placed through the API while the account has stayed connected; otherwise it answers 404 - cancel and place it again. A 200 means the change was not refused: read GET /orders to see the new levels.
POST /positions/{ticket}/close-byHedging accounts only (capability close_by): close against an opposite position, {"by": 339447522}.
DELETE /orders/{ticket}Cancel a working order. 404 no_such_order if it is not a working order. 409 not_confirmed means it may still be working: read GET /orders.

The realised result of a closed position is in GET /history/positions?position=TICKET (profit, commission, swap, fee, net).

10. Idempotency

11. When the outcome is unknown

The one rule to implement: treat an order or close as possibly done whenever you did not get a clear answer - 409 not_confirmed, any 5xx from an order or close, or a timeout or dropped connection before the reply. Do not resend it blindly.
  1. If you still have the same token and are within 10 minutes, repeat the exact request with the same Idempotency-Key and body: you get the original result, idempotency_in_progress (wait and repeat), or not_confirmed again. Later than that, or after a restart, the key is forgotten and a repeat is a new request - go to step 2 instead. After your key's expiry a repeat answers 403 trial_ended: go to step 2.
  2. Read GET /positions and trust an empty list only when book_read is true. Look for a position on that symbol and side opened after your request (opened_at), and check GET /history?days=1 for a fill that already closed. For pending orders read GET /orders.
  3. If you find it, use its ticket. If there is nothing and book_read is true, nothing was filled: send the order again with a new key.
  4. For a close: read GET /positions with book_read: true. If the ticket is gone, it is closed. If it is still there, compare its lots with what you expected: after a partial close the position stays open with fewer lots, so close again only the part that is still missing (with a new key). For an SL/TP change: read the position's sl/tp and send the PATCH again if needed. For a cancel: read GET /orders.
  5. After any reconnect, check before resending: idempotency keys do not carry over to a new token.

A 429 is the opposite case: nothing was sent, so retrying after Retry-After is correct.

12. Streaming

Open a WebSocket to wss://API_HOST/stream and send the first message - a JSON object with your session token - within 10 seconds. The token goes in the message, never in the URL.

-> {"token": "SESSION_TOKEN", "symbols": ["XAUUSD_l"], "interval_ms": 500}
<- {"type": "subscribed", "symbols": ["XAUUSD_l"], "account_id": "ACCOUNT_ID", "interval_ms": 500, ...}
<- {"type": "quote", "symbol": "XAUUSD_l", "bid": 4507.82, "ask": 4508.24, "time": "...", "age_seconds": 0.4, "source": "tick"}
<- {"type": "position_opened", "ticket": 555, "symbol": "XAUUSD_l", "lots": 0.01, ...}

If your client cannot use WebSockets, the same stream is available as server-sent events: GET https://API_HOST/stream?symbols=XAUUSD_l,EURUSD&interval_ms=500 with the Authorization header. Each message is event: <type> plus data: <json>. There is no subscribed event; options are query parameters (symbols=A,B, interval_ms=, ohlc=M1,M5, profit=true); refusals arrive as ordinary HTTP errors (400/401/422/429) before the stream starts.

13. Errors

Errors are JSON with a human-readable detail. Most also carry error, a stable code to branch on; any status can come with detail only - for example a missing or unknown API key, a missing token, an account id not on your key, an Idempotency-Key reused with a different body, or a bad query parameter. Branch on the HTTP status first, then on error when present (for idempotency_in_progress it is inside detail). Many errors carry retry_safe: true means retrying is safe (after Retry-After), false means do not retry blindly. An unexpected server error answers 500 with a plain-text body, so do not assume every error body is JSON.

StatuserrorMeaningRetry?
400bad_requestCannot be served as sent - e.g. connect to an id with nothing saved, a bad query parameter, bad stream optionsFix and resend
401(detail only)API key missing, unknown or revoked; or no session token sentNo - check the header or get a new key
401no_sessionUnknown or expired session tokenConnect again, once
401key_expiredYour key's validity, including the wind-down, is overNo - get a new key
401login_refusedYour broker needs something besides the password (e.g. a one-time password or a password change); reason says whatNot until fixed
403(detail only)That account id is not on your key, or this route is not available to your keyNo
403trial_endedPast expiry, inside the wind-down: you may close, cancel and move stops, not openNo - flatten
403quota_exhaustedYour key's allowance of new orders is used up; closing still worksNo
403not_ownedThat position or order belongs to other software on the account (another magic number); nothing was touchedNo
403investor_modeThe account is read-only (added with its investor password, or set read-only by the broker); nothing was sentIf source says the account was saved as investor: re-add with the master password and connect with "fresh": true. Otherwise ask your broker why it is read-only
404not_found / no_such_order / (detail only)No such open position or working order; a base symbol that does not resolve (with candidates)No
409not_confirmedYour broker accepted the request but the result could not be confirmedNever blindly - section 11
409idempotency_in_progress (in detail)The first request with this key is still running; nothing new was sentYes, same request after Retry-After
409(detail only)Idempotency-Key reused with a different body; or (on PUT /accounts) the login is already added under an id that is not yoursNo - fix the caller
409not_tradableThis session cannot open trades right now; nothing was placedAfter a few minutes; contact us if it persists (section 5)
422invalid_requestThe body or query failed validation; detail lists each field (loc, msg). Nothing was sent and no allowance usedFix and resend
422rejectedYour broker refused it; includes retcode and retcode_name (10014 bad volume, 10016 bad stops, 10024 too many requests)Depends on the retcode
422a reason codeRefused before sending: invalid_expiration, expiration_on_market, expiration_not_allowed, fill_policy_not_allowed, fill_policy_unverifiable, invalid_deviation, nothing_to_modify, level_unreadable (send both SL and TP)Fix and resend
422server_address_not_allowedYour key cannot set server_addressNo - contact us to add your broker
422unknown_serverWe do not recognise your account's MT5 server name yetNo - tell us your broker
429rate_limitedThrottled; carries Retry-After, retry_after and scope. Nothing was sentYes, after the delay
501unsupportedValid request this session cannot answer right now: a feature not in capabilities, data that has not arrived yet (/account just after connect, /quote on a closed market), or an unknown symbol on /symbols/{name}, /quote or /margin/requiredData not arrived: later, with backoff. Otherwise no
502 / 504(detail only)Bars or tick history temporarily unavailable from your broker (deal and position history answer 501 in that case)Yes, after a short wait
503broker_closed_sessionOn POST /connect: almost always a wrong login, password or server. Elsewhere: your broker closed the sessionNot with the same details - fix them first; never in a loop
503(detail only)/quote?max_age_ms=: no price as fresh as askedLater
500(plain text)Unexpected errorOn an order or close, treat it as possibly done (section 11); otherwise retry once after a short wait

14. Trial, expiry and limits

Your trial

Expiry has two steps

  1. At the expiry time, opening stops: new orders answer 403 trial_ended, and adding accounts stops.
  2. For the next 24 hours (until grace_until) you can still connect, read, close (POST /positions/close is the clean way to flatten), cancel and move stops, and list or delete your saved accounts.
  3. After that the key stops working (401 key_expired) and every session and stream ends.
Positions and working orders you leave open stay at your broker - nothing is closed or cancelled for you, and a working order can still fill after your key stops. Cancel what you do not want before the wind-down ends. To remove your saved MT5 password, call DELETE /accounts/{id} before the key stops working, or use Delete my saved broker passwords on the TS MT5 API page at any time, including after your key has ended. Anything still saved is removed about two weeks after your key stops working. Changing your MT5 password at your broker also makes any saved copy useless. Revoking a key has no wind-down: it stops at once and its sessions close, so close what you need first, or get a replacement key straight away (trial keys can be replaced only before the trial's expiry).

What counts as a new order

Every POST /orders that passes authentication, validation, rate limits and the trial checks uses one unit, whatever happens next: filled, refused by your broker (422 rejected), refused because the session is read-only, or 409 not_confirmed. The 422 checks made before sending (expiration_on_market, invalid_expiration, fill_policy_not_allowed and the like) count too. So do 409 not_tradable, 403 investor_mode, and a 404 or 501 from POST /orders. Not counted: 401, 422 invalid_request, 429, 403 trial_ended, 403 quota_exhausted, 409 idempotency_in_progress, a 409 for a reused key with a different body, and idempotent repeats. Closes, cancels and SL/TP changes never count.

Member keys

Team members and paid users get member keys: renewable, 30 days each (never past the membership's own end), with their own new-order allowance. They follow the same expiry, wind-down and headers as trial keys.

Rate limits

LimitDefaultCounted per
Reads (account, symbols, quotes including POST /quotes, bars, margin, positions, orders, history)20 per second, burst 40Account
New orders2 per second, burst 5Account
Closes, cancels, SL/TP changes5 per second, burst 10Account
POST /connect and PUT/GET/DELETE /accounts (failed attempts count too)1 per 5 seconds, burst 5API key
Streams4 open, 50 symbols eachAccount

Closing has its own larger budget, so a busy order loop can never stop you reducing risk. Positions and balance update when your broker pushes a change; polling faster than every few seconds gains little - use the stream. If your broker answers "too many requests" (retcode 10024), calls on that account pause briefly and may answer 429; wait for Retry-After.

15. Times and clocks

16. Checklist before going live

Questions or a broker we do not recognise yet? Contact us from the TS MT5 API page.