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.
1. Get started
- Log in and open the TS MT5 API page. Start your free 7-day trial (one per user).
- Copy three things: your API key (shown only once - it cannot be shown again), your base URL, and your two account ids.
- 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. - 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
| Credential | How to send it | Used for |
|---|---|---|
| API key | X-API-Key header | PUT, GET, DELETE /accounts and POST /connect |
| Session token | Authorization: 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
- A token ends after 15 minutes without a request or 12 hours of age, and it can end earlier (for example during maintenance or when your key is revoked). Any request, or an open stream, keeps it alive.
- On
401 no_session: callPOST /connectonce, then continue. If that connect answers401 key_expired, or 401 with noerrorcode, stop: your key no longer works.401 login_refusedis different - your broker needs something extra (for example anotp); itsreasonsays what. - Open one session and reuse its token.
POST /connectand the/accountscalls share a small budget (section 14), so never connect per request. - If a session is already open on the account, a new connect shares that broker login (
"reused": true). After you change a saved password, connect with"fresh": true: it first ends every other session on that MT5 login (their tokens then answer401 no_session), then logs in again. If that new login fails, those sessions stay ended. POST /disconnectends the session ({"closed": true}). Positions and orders are not touched. Idempotency keys belong to a session and are forgotten when it ends.
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 /accounts | API 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. |
- Saving does not check the login. A wrong login, password or server shows up at
POST /connect, usually as503 broker_closed_session. Fix it withPUTand connect again with"fresh": true. Do not retry a failing login in a loop - repeated wrong passwords can get your MT5 account locked by your broker. - If the save reply contains
"server_known": false, we do not recognise that server yet andPOST /connectwill answer422 unknown_server. Check the spelling; if it is right, email us your broker's name and the exact server name. You do not need to add the account again. - A re-save replaces the whole credential: send
login,passwordandserver(androle, if not master) every time. A session already open keeps the old login until you reconnect with"fresh": true. - Connecting to an id with nothing saved answers
400 bad_request. 409onPUT: that MT5 login on that server is already added under an id that is not yours. Contact us if the login is yours. You may add the same login under both of your own ids; they then share one connection.- You can list and remove your saved accounts until your key stops working, including during the wind-down after a trial ends (section 14). Adding or changing one (including a password change) stops at expiry.
- Deleting an account also clears its one-time setup, so re-adding the same login may need that setup again (section 5). While a session on that login is still open,
POST /connectkeeps sharing it even after a delete - disconnect first. - To change a password, re-save with
PUT- do not delete and re-add. Do not move a login between your two ids; contact us if you need to. server_addressis not accepted from your key (422 server_address_not_allowed): if we do not know your broker yet, contact us and we add it.
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):
read: true- your balance and positions have loaded.trade: true- orders can be accepted. Whenfalse, new orders answer409 not_tradableand nothing is sent to your broker; closing and stop changes may still work.
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 /symbols | Symbol 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-state | is_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/details | Balance, 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.
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 /history | Executed 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/positions | Closed 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/orders | Pending 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 BUY | 1 SELL | 2 BUY_LIMIT | 3 SELL_LIMIT |
| 4 BUY_STOP | 5 SELL_STOP | 6 BUY_STOP_LIMIT | 7 SELL_STOP_LIMIT |
- Size.
lotsis sent exactly as given - never rounded, capped or raised. Readmin_lots,max_lotsandlot_stepfromGET /symbols/{name}and send a multiple oflot_stepbetween them. An off-grid or too-small size is refused by your broker (422 rejected, retcode 10014) and still counts toward your allowance. - Price. Market orders (0, 1) need no
price. Types 2-7 needprice; 6 and 7 also needstop_limit_price(priceis the stop that triggers;stop_limit_priceis the limit order placed once it triggers). Prices, SL and TP are rounded to the symbol's tick, so levels you read back can differ slightly. - SL/TP. For a BUY put
slbelow andtpabove the price; mirror this for a SELL. Levels closer than the symbol's stop distance are refused (422 rejected, retcode 10016). - Slippage.
deviationis the most slippage you accept, in points (0-10000). The default is 1000 points, which is large on some symbols - set your own. - Fill policy. Optional
fill_policy:fok,iocorreturn; leave it out to use the symbol's own. Pending orders accept onlyreturn. - Expiry (pending orders).
expiration_type:gtc(default),today,specifiedorspecified_day, withexpirationas a future ISO-8601 UTC time (a time with no zone is read as UTC) - required forspecified/specified_day, refused forgtc/today. Theexpirationyou read back fromGET /ordersis in broker server time. Also sendserver_utc_offset_minutes(fromserverTimeZoneinGET /account/details). Any expiry on a market order is refused (422 expiration_on_market). - Comment.
commentis accepted but ignored: orders reach your broker without one. Identify orders by their ticket. - Check
GET /symbols/{name}/session-statefirst: an order sent outside trading hours may end in409 not_confirmed. - Netting accounts: close a position with the close endpoints (section 9), not with an opposite-side order. An opposite order that flattens the position can answer
409 not_confirmedeven though it filled - checkGET /positions. - With
fill_policy: "ioc"a market order can fill only part of the size and still answer 201: readposition.lots. - Deviation, fill policy, expiry and moving pending orders are newer options: try them on a demo account before relying on them live.
The reply
- Success is 201. A market order is answered only once the new position shows on your account: the reply carries
position. Useposition.ticketto modify or close it (a top-levelticketcan be 0). On a netting accountpositionis the whole combined position andnetting.filled_lotsis what this order added. - A pending order (2-7) is answered as soon as your broker accepts it. If its
ticketis 0, readGET /orders(andGET /positions, in case it already filled) before sending it again. - Timing. A market order usually takes a few seconds but can take up to about a minute, longer if another call on the same account is in progress (calls on one account run one at a time). Set your client timeout to at least 90 seconds for orders and closes, and always send an
Idempotency-Key.
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}/close | Body 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/close | Close 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-by | Hedging 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
- Send a unique
Idempotency-Keyper intended order or close (POST /orders,POST /positions/{ticket}/close,POST /positions/close). Never reuse a key across different requests. - Same key, same body, first call finished: you get the original result again, and no second order. Repeats do not use your allowance (they do count toward the 2-per-second order rate).
- Same key while the first call is still running (for example after a client timeout):
409withdetail.error = "idempotency_in_progress"andRetry-After. Nothing new was sent - wait, then repeat the same request. - Same key after the first call ended in
409 not_confirmed(or an unexpected error): you get409 not_confirmedagain and nothing is sent. Check your positions (section 11). - Same key after the first call was refused before reaching the broker (e.g.
422 rejected,403 quota_exhausted,409 not_tradable): the key is free again. - Same key with a different body:
409with only{"detail": "...already used with different parameters"}- a bug in your code. Do not retry; check positions before sending the corrected order under a new key. - Keys are remembered for 10 minutes, under the session token they were sent with. They are forgotten when the session ends, are not recognised under a new token, and do not survive a service restart.
11. When the outcome is unknown
409 not_confirmed, any 5xx from an order or close, or a timeout or dropped connection before the reply. Do not resend it blindly.- If you still have the same token and are within 10 minutes, repeat the exact request with the same
Idempotency-Keyand body: you get the original result,idempotency_in_progress(wait and repeat), ornot_confirmedagain. 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 answers403 trial_ended: go to step 2. - Read
GET /positionsand trust an empty list only whenbook_readistrue. Look for a position on that symbol and side opened after your request (opened_at), and checkGET /history?days=1for a fill that already closed. For pending orders readGET /orders. - If you find it, use its ticket. If there is nothing and
book_readistrue, nothing was filled: send the order again with a new key. - For a close: read
GET /positionswithbook_read: true. If the ticket is gone, it is closed. If it is still there, compare itslotswith 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'ssl/tpand send thePATCHagain if needed. For a cancel: readGET /orders. - 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, ...}
- Options (first message only - later messages are ignored; to change, close and reopen):
symbols(a list of strings, up to 50; base names are mapped,subscribed.symbolslists the broker names used, sorted, and events carry only broker names - so resolve names first),interval_ms(0-60000, default 0: the minimum gap between price updates; you always get the newest price, never a backlog),ohlc(timeframes M1 up to D1, e.g.["M1","M5"]: bars built from bid ticks, sent withclosed: falsewhile forming andclosed: truewhen done; events give the timeframe asminutes, e.g. 5 for M5; the first bar can be partial, andclosed: truearrives with the first tick of the next period, not exactly at the boundary) andprofit: true(floating P&L updates, sent on price ticks - so subscribe the symbols you hold). - Events:
subscribed,quote,position_opened,position_changed(lots, SL or TP changed, withwas),position_closed,order_update,ohlc,profit,heartbeat(every 15 s when quiet),lagged(events were dropped: re-readGET /positions),error(e.g. a symbol your account does not have - it stays insubscribed.symbolsand the error repeats, so resolve names first),session_closed(connect again, then reopen), andreconnecting/reconnected/reconnect_failed(the link to your broker; afterreconnect_failed, connect again with"fresh": true). Ignore types you do not use. - The stream reports changes, not state. Positions already open when you subscribe are not announced. Wait until
GET /positionsshowsbook_read: true, open the stream, then readGET /positionsagain and apply events on top, matched byticket. Re-read afterlagged,reconnectedor a drop of your own connection. - Position events cover every position on the account, whatever
symbolsyou chose (an emptysymbolslist gives trade events only). Working (pending) orders produce no events - pollGET /orders.position_closed.profitis the last floating value; the realised result is inGET /history/positions. - If nothing at all arrives for about 45 seconds plus your
interval_ms, treat the connection as dead and reopen with the same token. - Refusals close the socket: no first message in time, or a bad one: close code
4400(anerrormessage may say why); invalid or expired token: anerrormessage withno_sessionand code4401(connect again); more than 4 open streams on the account or more than 50 symbols:errorrate_limitedand code4429(checkscope: forstream_symbolsask for 50 or fewer symbols and do not retry; forstreamsclose another stream first - a just-closed stream can hold its slot for up to about 20 seconds, so back off with jitter, never retry in a tight loop). The WebSocket never sayskey_expired: ifPOST /connectthen answers401 key_expired, stop.
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.
| Status | error | Meaning | Retry? |
|---|---|---|---|
| 400 | bad_request | Cannot be served as sent - e.g. connect to an id with nothing saved, a bad query parameter, bad stream options | Fix and resend |
| 401 | (detail only) | API key missing, unknown or revoked; or no session token sent | No - check the header or get a new key |
| 401 | no_session | Unknown or expired session token | Connect again, once |
| 401 | key_expired | Your key's validity, including the wind-down, is over | No - get a new key |
| 401 | login_refused | Your broker needs something besides the password (e.g. a one-time password or a password change); reason says what | Not until fixed |
| 403 | (detail only) | That account id is not on your key, or this route is not available to your key | No |
| 403 | trial_ended | Past expiry, inside the wind-down: you may close, cancel and move stops, not open | No - flatten |
| 403 | quota_exhausted | Your key's allowance of new orders is used up; closing still works | No |
| 403 | not_owned | That position or order belongs to other software on the account (another magic number); nothing was touched | No |
| 403 | investor_mode | The account is read-only (added with its investor password, or set read-only by the broker); nothing was sent | If 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 |
| 404 | not_found / no_such_order / (detail only) | No such open position or working order; a base symbol that does not resolve (with candidates) | No |
| 409 | not_confirmed | Your broker accepted the request but the result could not be confirmed | Never blindly - section 11 |
| 409 | idempotency_in_progress (in detail) | The first request with this key is still running; nothing new was sent | Yes, 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 yours | No - fix the caller |
| 409 | not_tradable | This session cannot open trades right now; nothing was placed | After a few minutes; contact us if it persists (section 5) |
| 422 | invalid_request | The body or query failed validation; detail lists each field (loc, msg). Nothing was sent and no allowance used | Fix and resend |
| 422 | rejected | Your broker refused it; includes retcode and retcode_name (10014 bad volume, 10016 bad stops, 10024 too many requests) | Depends on the retcode |
| 422 | a reason code | Refused 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 |
| 422 | server_address_not_allowed | Your key cannot set server_address | No - contact us to add your broker |
| 422 | unknown_server | We do not recognise your account's MT5 server name yet | No - tell us your broker |
| 429 | rate_limited | Throttled; carries Retry-After, retry_after and scope. Nothing was sent | Yes, after the delay |
| 501 | unsupported | Valid 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/required | Data 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 |
| 503 | broker_closed_session | On POST /connect: almost always a wrong login, password or server. Elsewhere: your broker closed the session | Not with the same details - fix them first; never in a loop |
| 503 | (detail only) | /quote?max_age_ms=: no price as fresh as asked | Later |
| 500 | (plain text) | Unexpected error | On 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
- One free trial per user: 7 days from your first key, with 300 new orders in total. A replacement key keeps the trial's end date and what is left of the allowance. A new trial cannot be started after the window ends.
- Responses to requests made with your key or session carry
X-Trial-Status(active, orgraceduring the wind-down),X-Trial-Expires(ISO-8601 UTC),X-Trial-Seconds-Left(until expiry; 0 during the wind-down) andX-Trial-Orders-Left.POST /connectreturns the same in itstrialobject, plusgrace_until- when the wind-down ends and the key stops working.
Expiry has two steps
- At the expiry time, opening stops: new orders answer
403 trial_ended, and adding accounts stops. - For the next 24 hours (until
grace_until) you can still connect, read, close (POST /positions/closeis the clean way to flatten), cancel and move stops, and list or delete your saved accounts. - After that the key stops working (
401 key_expired) and every session and stream ends.
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
| Limit | Default | Counted per |
|---|---|---|
Reads (account, symbols, quotes including POST /quotes, bars, margin, positions, orders, history) | 20 per second, burst 40 | Account |
| New orders | 2 per second, burst 5 | Account |
| Closes, cancels, SL/TP changes | 5 per second, burst 10 | Account |
POST /connect and PUT/GET/DELETE /accounts (failed attempts count too) | 1 per 5 seconds, burst 5 | API key |
| Streams | 4 open, 50 symbols each | Account |
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
- Broker server time (the broker's own clock, no zone applied): bar
timeandfrom/to, historyfrom/toand dealtime, quotetime, symbol session times. - Also broker server time: pending-order
expiration,time_setupandtime_doneinGET /ordersandGET /history/orders; open and close times inGET /history/positions; streamohlcbartime. (Theexpirationyou send is UTC.) - UTC:
pnl_at, theX-Trial-*headers, streamheartbeatandprofittimes, and positionopened_atonce your broker's time zone is known (serverTimeZonenot null inGET /account/details). Until thenopened_atis the broker's own clock: allow for the offset when comparing it with your request time. serverTimeZoneinGET /account/detailsis the server's UTC offset in minutes. Pass it asserver_utc_offset_minuteswhenever you set a pending-order expiry.
16. Checklist before going live
- One session per strategy process; reconnect once on
401 no_session. Never retry a failingPOST /connectin a loop. - Check
session_ready.tradeafter connecting. - Resolve symbol names; size from
min_lots/max_lots/lot_step; checksession-state. Idempotency-Keyon every order and close; client timeout of 90 s or more.- On
409 not_confirmed, a 5xx or a timeout: check positions, never resend blindly. - Trust an empty position list only when
book_readistrue. - Stream for updates; baseline with
GET /positions. - Watch
X-Trial-Orders-Leftandgrace_until; flatten before the key stops. - On a live account, start small. Every order is real.
Questions or a broker we do not recognise yet? Contact us from the TS MT5 API page.