Place an MT5 order over a REST API: the safe sequence

12 min readMetaKit

Your first order through an API is the one that teaches you the most, and it usually teaches you by filling twice, filling at the wrong size, or bouncing with a five-digit retcode you have to go and look up. All three are avoidable. The avoidance is a fixed sequence of calls made in the same order every time, and this is that sequence for placing an MT5 order over the MetaKit REST API.

The trading surface is deliberately small. POST /v1/accounts/{id}/orders opens a market position or places a pending order, PATCH /v1/accounts/{id}/positions/{ticket} moves stops, DELETE on the same path closes, and DELETE /v1/accounts/{id}/orders/{ticket} cancels a pending order. That's it. No magic numbers, no filling-mode selection, no close-by; if you need those you're writing MQL5, not calling a REST API. The exact contract for all four is under "Trading" in llms.txt. (If you'd rather not write execution code at all, a copier replicates one account onto another and you only ever trade the source.)

Two gates before an MT5 order can go anywhere

You need a full API key and a full slot, and the account has to be connected. Both "full"s are separate gates, and they get confused constantly because they use the same word.

The key scope is about what a bearer token is allowed to do. A readonly key can GET anything you own and nothing else; hand one to an AI agent or a dashboard and the worst it can do is read. Send a POST with it and you get 403 insufficient_scope before the request is even looked at. The scopes post is about choosing well.

The slot tier is about what the terminal behind the account can physically do. A readonly slot logs in with the investor password, and an investor-password terminal cannot trade, no matter what key you present. That's 403 account_readonly. A full slot logs in with the master password and can.

So the guards run in this order before the terminal is touched: readonly key, then readonly slot, then 409 account_not_connected if status is anything but connected. One GET tells you where you stand:

export METAKIT_KEY="stk_live_..."
curl -s https://api.metakit.cloud/v1/accounts/2 -H "Authorization: Bearer $METAKIT_KEY"

You want "type": "full" and "status": "connected". If you don't have a connected account yet, the connect post gets you there.

Read the symbol spec once and cache it

Everything that can get an order refused before it reaches the broker lives in the symbol spec, so read it before you compute anything:

curl -s https://api.metakit.cloud/v1/accounts/2/symbols/XAUUSD.m -H "Authorization: Bearer $METAKIT_KEY"

The fields you'll use: digits (so you know what a point is: ten to the minus digits), volume_min, volume_max, volume_step, stops_level in points, freeze_level, trade_mode, and contract_size plus tick_value for the money maths. A 404 not_found here means the symbol name is wrong for this broker, which is nearly always a suffix problem: it's XAUUSD.m here, GOLD there, XAUUSD.pro somewhere else. The suffix post covers that.

Cache the spec per account and symbol. It changes rarely (brokers adjust it at rollover or when they change margin rules), every order needs it, and re-fetching it per signal is a wasted round trip to a real terminal. Refresh it once a day, and immediately after any 422 you didn't expect. The one thing not to use from the cached copy is bid and ask: those are a snapshot and can lag.

Size the order to volume_step yourself

The API refuses off-step volumes with 422 invalid_volume. It never rounds. Send 0.123 lots on a 0.01 step and nothing happens except an error, and that is deliberate: rounding is a risk decision and we're not going to make it for you. Round up and the position is bigger than you sized. Round down and it might fall below volume_min, in which case the correct action is probably to skip the trade, not to trade the minimum.

So floor to the step yourself and check the bounds:

import math
 
def to_step(lots: float, spec: dict) -> float:
    step = spec["volume_step"]
    stepped = round(math.floor(round(lots / step, 6)) * step, 8)
    if stepped < spec["volume_min"]:
        raise ValueError(f"{stepped} is below volume_min {spec['volume_min']}")
    return min(stepped, spec["volume_max"])

The inner round(..., 6) is there because 0.3 / 0.1 is 2.9999999999999996 in floating point and a naive floor turns 0.3 lots into 0.2. Getting from cash-at-risk to that lots number in the first place is its own post, position sizing from the symbol spec, with the lot size and pip value explainer if you want the theory.

Re-check the entry against the quote right before sending

Your signal was computed on a price from some moment ago. The spec's bid and ask are from a snapshot. Neither is what the broker will fill you at. One call fixes that:

curl -s "https://api.metakit.cloud/v1/accounts/2/quote?symbol=XAUUSD.m" -H "Authorization: Bearer $METAKIT_KEY"
{ "symbol": "XAUUSD.m", "bid": 2331.40, "ask": 2331.65, "last": 0, "time": "2026-09-22T14:03:10.000Z" }

Two things to do with it. First, compare ask (for a buy) with the price your signal wanted, and if it has moved further than you're willing to chase, don't send. Second, place your stop and target relative to this price and at least stops_level points away from it: on XAUUSD.m with digits: 2 and stops_level: 30, that's 0.30 in price. A stop inside that band is 422 invalid_stops before the terminal sees it. last is 0 on most FX and metals; only symbols with a last-trade feed populate it.

Send it with an Idempotency-Key

The header is the difference between a network blip costing you a retry and a network blip costing you a second position. Use your own signal id, the same value on every attempt for this idea:

curl -s -X POST https://api.metakit.cloud/v1/accounts/2/orders \
  -H "Authorization: Bearer $METAKIT_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idea-7f3a" \
  -d '{ "symbol": "XAUUSD.m", "side": "buy", "type": "market", "volume": 0.12, "sl": 2310.50, "tp": 2362.00, "deviation": 20, "comment": "idea 7f3a" }'

deviation is the maximum slippage you'll accept on a market fill, in points, default 20. comment is capped at 31 characters by MT5 itself. type defaults to market, so you can omit it.

A pending order is the same call with type, price and optionally expiration:

curl -s -X POST https://api.metakit.cloud/v1/accounts/2/orders \
  -H "Authorization: Bearer $METAKIT_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idea-7f3a-retest" \
  -d '{ "symbol": "XAUUSD.m", "side": "buy", "type": "limit", "volume": 0.12, "price": 2325.00, "sl": 2310.50, "tp": 2362.00, "expiration": "2026-09-22T21:00:00Z" }'

The price has to be on the correct side of the market and at least stops_level away: a limit buys below the current price or sells above it, a stop buys above or sells below. Wrong side is 422 invalid_stops, same as a bad SL. And expiration is compared on the broker's clock, not UTC, which is a whole post of its own; omit it for good-till-cancelled.

Read the 201

{
  "status": "filled",
  "order_ticket": 48812231,
  "position_ticket": 48812231,
  "deal_ticket": 91002817,
  "fill_price": 2331.42,
  "volume": 0.12,
  "sl": 2310.50,
  "tp": 2362.00,
  "retcode": 10009,
  "retcode_message": "Request completed",
  "time": "2026-09-22T14:03:11.000Z"
}

status is filled for a market order and placed for a pending one. The three tickets are three different MT5 objects, and the orders, deals and positions post explains why there are three. For what you do next: position_ticket is the one you PATCH and DELETE; deal_ticket is the execution record you'll see again in /deals; order_ticket is the instruction. On a hedging account position_ticket equals order_ticket; on a netting account it's the symbol's single position, which may already have existed before this order.

For a placed pending order, position_ticket, deal_ticket and fill_price are null, and price and expiration echo what you sent. The order_ticket is what you'd DELETE .../orders/{ticket} to cancel. retcode is 10009 for a fill and 10008 for a placement, and fill_price is where you actually got in, which is not necessarily the ask you quoted a moment ago.

If you get a 200 instead of a 201, with Idempotent-Replayed: true, you've sent this key before within 24 hours: this is the stored response to the first attempt and nothing was sent to the terminal.

Three outcomes you must handle differently

Everything that isn't a 2xx falls into one of three buckets, and reacting the same way to all three is how orders get lost or doubled.

A 422 from pre-validation. invalid_volume, invalid_stops or symbol_not_tradeable, checked against the symbol spec before the terminal is touched, so there's no retcode in the body. The bug is in your request. Fix the volume, move the stop, check trade_mode, and send again with a new key, because the old key now replays the 422 for 24 hours.

A 422 with status: "rejected". The broker refused. The body carries the raw MT5 retcode and retcode_message alongside the error:

{
  "error": { "code": "order_rejected", "message": "There is not enough money to complete the request" },
  "status": "rejected",
  "retcode": 10019,
  "retcode_message": "There is not enough money to complete the request"
}

Where the retcode has an obvious meaning we map it: 10014 and 10038 become invalid_volume, 10016 is invalid_stops, 10017, 10018 and 10042 through 10044 are symbol_not_tradeable. Everything else is order_rejected and you branch on retcode. What each one means and what to do about it is the retcodes field guide. Same rule as above: new key for the corrected attempt.

A 504 terminal_timeout. The broker didn't answer within 10 seconds. The order may have filled. This is the one that fills twice if you treat it like a normal error: retrying with a fresh key sends a second order, and retrying with the same key replays the stored 504, which is correct but doesn't tell you what happened either. The rule is reconcile, then retry: read /positions (or wait for the position.opened webhook), look for a position matching your symbol, side and volume, and only mint a new key if it isn't there. The idempotency post walks through exactly why.

One more you'll see occasionally: 409 idempotency_in_progress means the first attempt is still executing. Wait a second and resend with the same key.

Move the stop, then close half

Both take the position_ticket, both take an Idempotency-Key, and both return the same shape as the order response with a different status.

PATCH with sl and/or tp. A field you omit keeps its value; 0 or null clears it. The new stop is validated against the current close price (bid for a long, ask for a short) and stops_level, so a break-even stop on a long that hasn't moved yet is 422 invalid_stops. Response status is modified.

DELETE with a volume closes that much; omit the body for a full close. The partial volume has to sit on volume_step and be at most the open volume, so run it through to_step too. You get partially_closed with remaining_volume, or closed.

The whole sequence as one script

Python 3, requests only. Set METAKIT_KEY and METAKIT_ACCOUNT and run it against a demo account.

"""Place, protect and partially close one MT5 order through MetaKit."""
import math
import os
import sys
import time
import uuid
 
import requests
 
BASE = "https://api.metakit.cloud/v1"
ACCOUNT = int(os.environ.get("METAKIT_ACCOUNT", "2"))
SYMBOL = "XAUUSD.m"
# The idempotency key is the signal's own id, decided before any request is sent.
SIGNAL_ID = os.environ.get("SIGNAL_ID") or f"idea-{uuid.uuid4().hex[:8]}"
 
s = requests.Session()
s.headers.update({
    "Authorization": f"Bearer {os.environ['METAKIT_KEY']}",
    "Content-Type": "application/json",
})
 
 
def url(path):
    return f"{BASE}/accounts/{ACCOUNT}{path}"
 
 
def get(path, **params):
    r = s.get(url(path), params=params, timeout=15)
    if not r.ok:
        sys.exit(f"GET {path}: {r.status_code} {r.json()['error']['code']}")
    return r.json()
 
 
def send(method, path, key, body=None):
    return s.request(method, url(path), json=body, headers={"Idempotency-Key": key}, timeout=20)
 
 
def to_step(lots, spec):
    """Floor to volume_step. Never round up: that is more risk than you sized."""
    step = spec["volume_step"]
    stepped = round(math.floor(round(lots / step, 6)) * step, 8)
    if stepped < spec["volume_min"]:
        raise ValueError(f"{lots} lots floors to {stepped}, below volume_min {spec['volume_min']}")
    return min(stepped, spec["volume_max"])
 
 
def explain(r):
    """The three failure shapes need three different reactions."""
    payload = r.json()
    code = payload["error"]["code"]
    if r.status_code == 422 and payload.get("status") == "rejected":
        print(f"broker rejected: {code} retcode={payload['retcode']} {payload['retcode_message']}")
    elif r.status_code == 422:
        print(f"pre-validation: {code}: {payload['error']['message']} (fix the request, new key)")
    elif r.status_code == 504:
        print("terminal_timeout: the order MAY have filled. Reconcile against /positions first.")
    else:
        print(f"{r.status_code} {code}: {payload['error']['message']}")
 
 
# 1. Both gates. The key scope is enforced server-side; the slot and status you can check.
acct = get("")
if acct["type"] != "full" or acct["status"] != "connected":
    sys.exit(f"account {ACCOUNT} is type={acct['type']} status={acct['status']}; cannot trade")
 
# 2. Symbol spec, once. Cache this in real code.
spec = get(f"/symbols/{SYMBOL}")
if spec["trade_mode"] not in ("full", "long_only"):
    sys.exit(f"{SYMBOL} trade_mode is {spec['trade_mode']}; cannot buy")
point = 10 ** -spec["digits"]
min_dist = spec["stops_level"] * point
 
# 3. Size to volume_step yourself. 0.123 becomes 0.12, not 0.13.
volume = to_step(0.123, spec)
 
# 4. Re-quote right before sending; the spec's bid/ask can lag.
q = get("/quote", symbol=SYMBOL)
ask = q["ask"]
sl = round(ask - max(20.0, min_dist), spec["digits"])
tp = round(ask + max(30.0, min_dist), spec["digits"])
 
# 5. Market buy under the signal's key. Same key on every attempt.
order = {
    "symbol": SYMBOL, "side": "buy", "type": "market", "volume": volume,
    "sl": sl, "tp": tp, "deviation": 20, "comment": f"signal {SIGNAL_ID}"[:31],
}
r = send("POST", "/orders", SIGNAL_ID, order)
while r.status_code == 409 and r.json()["error"]["code"] == "idempotency_in_progress":
    time.sleep(1)
    r = send("POST", "/orders", SIGNAL_ID, order)
if not r.ok:
    explain(r)
    sys.exit(1)
 
# 6. Read the 201 (or a 200 replay of it).
filled = r.json()
if r.headers.get("Idempotent-Replayed") == "true":
    print("replayed an earlier result for this key; nothing new was sent")
print(f"{filled['status']} {filled['volume']} lots at {filled['fill_price']} "
      f"order={filled['order_ticket']} position={filled['position_ticket']} "
      f"deal={filled['deal_ticket']} retcode={filled['retcode']} {filled['retcode_message']}")
ticket = filled["position_ticket"]
 
# 7. Tighten the stop by 5 dollars. Validated against bid and stops_level.
r = send("PATCH", f"/positions/{ticket}", f"{SIGNAL_ID}-sl-1", {"sl": round(sl + 5.0, spec["digits"])})
if r.ok:
    print("stop:", r.json()["status"], r.json()["sl"])
else:
    explain(r)
 
# 8. Close half. The partial volume must also sit on volume_step.
half = to_step(filled["volume"] / 2, spec)
r = send("DELETE", f"/positions/{ticket}", f"{SIGNAL_ID}-close-half", {"volume": half, "deviation": 20})
if r.ok:
    out = r.json()
    print(out["status"], "remaining", out.get("remaining_volume"))
else:
    explain(r)

Run it on a demo account and watch the 0.123 in step 3 become 0.12. Then change it to 0.005 and watch the script refuse before a single request goes out. The refusals are the feature. Full field reference at app.metakit.cloud/docs.