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.