Idempotent MT5 orders: never double-fill on retry
Why retrying a MetaTrader 5 order can open a second position, and how the fxapis Idempotency-Key prevents it: 24-hour keys, replays, in-flight and reuse conflicts, the unknown outcome, and key patterns for signals, copiers and queues.
Every trading integration eventually sends the same order twice. A mobile connection drops after the tap, a load balancer times out, a queue redelivers a job, a user double-clicks. On most APIs a duplicate request is an annoyance. On a trading API it is a second position — real exposure nobody chose.
This guide explains where duplicates come from, exactly how the
Idempotency-Key header behaves on fxapis, and how to choose keys so that a
retry is always safe.
Why retries duplicate trades
When your code sends an order, three things can happen: the answer comes back, an error comes back, or nothing comes back. The third case is the hard one. The request may have died before it reached the broker, or the broker may have filled it and the answer was lost on the way back. From your side the two are indistinguishable.
MetaTrader 5 makes this concrete. Its trade server answers with return codes, and some of them — a timeout, for example — describe what happened to the wait, not to the order. An order that "timed out" may well be open.
Without a way to ask "did my earlier request already do this?", a client has two bad choices: resend and risk a double fill, or give up and risk missing the trade. The idempotency key is that question, asked by the server on your behalf.
How the Idempotency-Key works
Send any unique string, up to 200 characters, in the Idempotency-Key header
of an order:
curl -sS -X POST "https://api.fxapis.com/v1/accounts/$ACCOUNT_ID/orders/market" \
-H "Authorization: Bearer $FXAPIS_KEY" \
-H "Idempotency-Key: open:signal_9931:member_4821" \
-H "Content-Type: application/json" \
-d '{ "symbol": "XAUUSD", "side": "buy", "volume": "0.05" }'It is honoured on the three calls that can create exposure or remove it: market orders, pending orders and closing a position. The first request with a key claims it; everything after that is decided by the claim:
| You send | You get |
|---|---|
| Same key, same body, first request finished | The same status and body, replayed, with the header idempotent-replay: true. Nothing is sent to the broker. |
| Same key, same body, first request still running | 409 IDEMPOTENCY_IN_FLIGHT. Wait a moment and send it again with the same key. |
| Same key, different body or endpoint | 409 IDEMPOTENCY_KEY_REUSED. Nothing is placed. This is a bug in how keys are made. |
Same key after SEND_FAILED | A genuine new attempt. The order provably never reached the broker, so the key was released. |
Keys are remembered for 24 hours and belong to your workspace. A key used on one endpoint cannot be used on another: open and close need different keys.
What is replayed, exactly
Whatever the first request finished with. That includes failures:
- A 201 fill is replayed as the same 201.
- A 422
ORDER_REJECTEDis replayed as the same rejection. If the broker refused because the price moved (retryable: true) and you decide to trade again, that is a new decision — use a new key. - A 502
ORDER_UNRESOLVEDis replayed as the same 502. The key stays claimed on purpose: this is exactly the case where a second order must be impossible.
A key is released only when we know nothing reached the broker: after
SEND_FAILED, and when the request was refused before any order existed — for
example an account that could not come online in time (ACCOUNT_NOT_READY).
Retrying those with the same key places the order normally.
The unknown outcome
ORDER_UNRESOLVED (502) means we do not know yet whether the broker executed
the order. The order is recorded in state unknown, and it may be live right
now.
Never resend it — not with the same key (you would get the same 502 back) and certainly not with a new key (that is how double fills happen). Instead:
- Take
details[0].orderIdfrom the error. - Poll
GET /v1/orders/{id}every few seconds. - We confirm the result against the broker's own records automatically. The order moves to
filled,partially_filledorrejected— whatever actually happened. - To confirm right away instead of waiting, call
POST /v1/accounts/{id}/reconcile.
If the broker's records match more than one trade, the order is left in
unknown rather than guessed, and needs a person. GET /v1/orders?state=unknown
is the list worth alerting on. The details are in
Trading → Confirming results.
A retry loop that is always safe
import time, uuid, requests
RETRY_SAME_KEY = {"SEND_FAILED", "IDEMPOTENCY_IN_FLIGHT", "ACCOUNT_NOT_READY", "NO_RUNTIME", "RATE_LIMITED"}
def place_once(session, url, body, key):
for attempt in range(6):
try:
r = session.post(url, json=body, headers={"Idempotency-Key": key}, timeout=90)
except requests.RequestException:
time.sleep(min(2 ** attempt, 10)) # no answer seen: same key, ask again
continue
if r.status_code == 201:
return r.json()["data"]
error = r.json()["error"]
if error["code"] == "ORDER_UNRESOLVED":
return {"state": "unknown", "id": error["details"][0]["orderId"]} # poll it; never resend
if error["code"] in RETRY_SAME_KEY:
time.sleep(int(r.headers.get("retry-after", 0)) or min(2 ** attempt, 10))
continue
raise RuntimeError(f"{error['code']}: {error['message']}") # rejected, invalid, quota…
raise RuntimeError("gave up; the same key is still safe to retry later")The rule it encodes: the key is chosen once, before the first attempt, and never changes between attempts. Generating a fresh UUID inside the loop defeats the whole mechanism. Complete programs in seven languages are in the language guides.
Choosing keys
A key should identify the decision to trade, not the HTTP request. Two requests that carry the same decision must carry the same key; two different decisions must never share one.
One user action → a key made when the action is made
For a button in your app, create the key when the order is created in your database, store it on that row, and send it on every attempt. If your process crashes between sending and recording the answer, the restarted process finds the row, sends the same key, and gets the original answer back.
// When the user confirms — before any request is sent.
const intent = await db.orderIntents.insert({
userId, accountId, symbol, side, volume,
idempotencyKey: `intent:${crypto.randomUUID()}`,
});
// Later, and on every retry, from any worker:
await placeOnce(intent.accountId, intent, intent.idempotencyKey);A signal taken by many members → signal + member
open:signal_9931:member_4821A member tapping twice, their phone retrying, and your worker retrying all collapse into one order. A different member, or a different signal, gets a different key. This is the pattern Signals and click-to-trade uses.
A trade copier → master deal + follower
copy:open:master_deal_5518823:follower_7f3a
copy:close:master_deal_5518901:follower_7f3aThe master's broker deal id is unique and stable, so however many times your poller sees the same deal, each follower gets one order for it. See Build an MT5 trade copier.
Closes and partial closes
For a full close, close:{accountId}:{ticket} is a good key. For partial
closes of the same position, add something that distinguishes one decision
from the next — a sequence number or the id of the rule that fired — because a
second partial close with the same key and body would be replayed, not
executed, and one with the same key but a different volume would be refused as
IDEMPOTENCY_KEY_REUSED.
A repeated close after the position is already gone returns 404
POSITION_NOT_FOUND ("That position is already closed"). Treat that as done,
not as an error to retry.
Queue jobs
Queues deliver at least once. Put the key in the job payload when the job is enqueued — never generate it inside the job — so a redelivered job reuses it.
Multi-account orders
Multi-account orders accept an
Idempotency-Key too. It is stored with the order itself: sending the same key
again returns the multi-account order that key created — 200 with
idempotent-replay: true — instead of creating another. That matters more
than anywhere else, because a duplicated multi-account order is a duplicated
trade on every account at once. Use a key per decision, for example
wave:signal_9931.
What idempotency does not cover
- Other ways into the same account. A key deduplicates requests to fxapis. If someone also trades
the account from the MetaTrader desktop or another platform, those trades are theirs — they show
up in deals with
orderId: null. - Keys older than 24 hours. After that a key is forgotten. Do not rely on it to deduplicate a
retry of yesterday's order; check
GET /v1/ordersfirst. - Moving stops and cancelling.
modifyandcanceldo not take the header. Levels are absolute prices, so repeating amodifyeither sets the same levels again or is refused by the broker as a no-change; neither opens or closes anything.
Checklist
- Every market order, pending order and close sends an
Idempotency-Key. - The key is created once, stored with the intent, and reused on every retry.
- Keys identify decisions: signal + member, master deal + follower, a stored intent id.
- Open, close and each partial close have different keys.
-
ORDER_UNRESOLVEDis never resent; the order is polled until it leavesunknown. -
ORDER_REJECTEDwithretryable: trueis retried only as a new decision, with a new key. -
GET /v1/orders?state=unknownis monitored.
Related: Errors, Trading → The three failures, and the Python and Node.js guides for complete programs.
Build an MT5 trade copier with the API
Copy trades from a master MetaTrader 5 account to many follower accounts over HTTP: detect the master's deals by polling, fan out with multi-account orders, size by balance ratio, mirror closes and stop-loss / take-profit changes, and stay idempotent per master deal.
TradingView alerts to MT5 — no EA, no VPS
Turn TradingView alerts into MetaTrader 5 orders with a secret webhook URL: create a hook in the console or the API, set up the TradingView alert step by step, message formats and placeholders for indicators and strategies, closing, idempotency, the IP allowlist, troubleshooting and limits.