# Idempotent MT5 orders: never double-fill on retry

URL: https://docs.fxapis.com/guides/idempotency

> 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…



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 [#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 [#how-the-idempotency-key-works]

Send any unique string, up to 200 characters, in the `Idempotency-Key` header
of an order:

```bash
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](/api-reference/trading/place-a-market-order),
[pending orders](/api-reference/trading/place-a-limit-stop-or-stop-limit-order) and
[closing a position](/api-reference/trading/close-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 [#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 [#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}`](/api-reference/history/fetch-one-order) 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`](/api-reference/trading/confirm-pending-results-with-the-broker-now).

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](/trading#confirming-results).

## A retry loop that is always safe [#a-retry-loop-that-is-always-safe]

```python
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](/guides).

## Choosing keys [#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 [#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.

```ts
// 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 [#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](/signals) uses.

### A trade copier → master deal + follower [#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](/guides/copy-trading).

### Closes and partial closes [#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 [#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]

[Multi-account orders](/guides/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 [#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](/api-reference/history/an-accounts-full-deal-history) 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 [#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](/errors), [Trading → The three failures](/trading#the-three-failures),
and the [Python](/guides/python) and [Node.js](/guides/nodejs) guides for complete programs.
