Docs

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 sendYou get
Same key, same body, first request finishedThe same status and body, replayed, with the header idempotent-replay: true. Nothing is sent to the broker.
Same key, same body, first request still running409 IDEMPOTENCY_IN_FLIGHT. Wait a moment and send it again with the same key.
Same key, different body or endpoint409 IDEMPOTENCY_KEY_REUSED. Nothing is placed. This is a bug in how keys are made.
Same key after SEND_FAILEDA 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_REJECTED is 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_UNRESOLVED is 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:

  1. Take details[0].orderId from the error.
  2. Poll GET /v1/orders/{id} every few seconds.
  3. We confirm the result against the broker's own records automatically. The order moves to filled, partially_filled or rejected — whatever actually happened.
  4. 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_4821

A 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_7f3a

The 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/orders first.
  • Moving stops and cancelling. modify and cancel do not take the header. Levels are absolute prices, so repeating a modify either 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_UNRESOLVED is never resent; the order is polled until it leaves unknown.
  • ORDER_REJECTED with retryable: true is retried only as a new decision, with a new key.
  • GET /v1/orders?state=unknown is monitored.

Related: Errors, Trading → The three failures, and the Python and Node.js guides for complete programs.

On this page