Place one order across many MT5 accounts
Send the same MetaTrader 5 trade to many accounts in one API call — up to 1,000 accounts, within your plan's limit: per-account volumes, barrier policies for accounts that are not online, scheduled execution with executeAt and expiresAt, per-account results, cancelling, and dispatch spread.
A multi-account order places one trade on many MetaTrader 5 accounts at the same moment — a signal service entering for every subscriber, a fund manager trading a set of managed accounts, a copier fanning out a master's trade. You make one request; every account gets its own order, sent together.
In the API these are called execution waves (/v1/execution-waves).
POST /v1/execution-waves one trade, many accounts → 201 at once
GET /v1/execution-waves/{id} poll until state = settled
GET /v1/orders/{orderId} any one account's order, in full
POST /v1/execution-waves/{id}/close close what it opened, every account at once
POST /v1/execution-waves/{id}/retry a new one for the accounts it did not reachTest with demo accounts
Every key reaches real brokers — fx_test_ is only a label. Build and test with broker demo
accounts first.
The request
curl -sS -X POST "https://api.fxapis.com/v1/execution-waves" \
-H "Authorization: Bearer $FXAPIS_KEY" \
-H "Idempotency-Key: wave:signal_9931" \
-H "Content-Type: application/json" \
-d '{
"accountIds": [
"0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51",
"5b2e8a10-93c4-4f7d-8e61-0a9c2b4d6e13",
"c41d7e22-6f0b-4a95-b3e8-7d1f5a2c9b80"
],
"symbol": "EURUSD",
"side": "buy",
"volume": "0.01",
"weights": { "5b2e8a10-93c4-4f7d-8e61-0a9c2b4d6e13": "0.05" },
"stopLoss": "1.12900",
"takeProfit": "1.14200",
"barrierPolicy": "release-ready",
"label": "London open — EURUSD long",
"clientWaveId": "signal_9931"
}'| Field | Meaning |
|---|---|
accountIds | 1 to 1,000 account ids, and no more than your plan's accounts per multi-account order (Production 250, Business 1,000; see pricing for each plan). An id listed twice is traded once. Every id must belong to your workspace, or the whole request is refused with 404 ACCOUNT_NOT_FOUND. |
symbol or instrument, side | The trade. symbol is traded by that exact name on every account. instrument (e.g. XAUUSD, from GET /v1/instruments) is traded under each account's own broker symbol — see below. |
volume | Lots per account, as a string. |
weights | Optional. Per-account volume in lots, keyed by account id, overriding volume for those accounts. |
stopLoss, takeProfit | Optional absolute prices, applied to every account's order. |
barrierPolicy | What to do about accounts that are not online in time. Default release-ready. |
executeAt | Optional RFC 3339 time at which to send. Omit it to send as soon as every account is online — waiting up to 60 seconds for accounts still logging in. |
expiresAt | Optional time after which to stop waiting for accounts. |
label, clientWaveId, comment | Your own references; comment goes on each MT5 order. |
The response is 201 straight away, with the order in state planned and
one entry per account in legs. The work happens in the background: poll
GET /v1/execution-waves/{id}. Reference:
Place one trade on many accounts.
Multi-account orders are a plan feature; a plan without them answers 402
FEATURE_NOT_IN_PLAN, and a request larger than the plan allows answers
402 QUOTA_EXCEEDED before anything is created.
Accounts at different brokers: name an instrument
Brokers name the same market differently — gold is XAUUSD.a at one, GOLD at
another, XAUUSDm at a third. Send instrument instead of symbol and each
account trades its own broker's symbol for it:
{
"accountIds": ["…", "…", "…"],
"instrument": "XAUUSD",
"side": "buy",
"volume": "0.10"
}Each account's symbol is worked out once, when the order is made, and shown on
its leg (legs[].symbol); the order itself has symbol: null and
instrument: "XAUUSD". If any account cannot trade the instrument, nothing
is placed: the request is refused with 422 INSTRUMENT_UNAVAILABLE, and
details names each account and why — INSTRUMENT_NOT_OFFERED,
INSTRUMENT_AMBIGUOUS (with candidates), or SYMBOLS_NOT_SYNCED for an
account that has never been online. Bring that account online once, or leave it
out, and send again.
Sizing with weights
weights values are volumes, not multipliers. With "volume": "0.01" and
"weights": {"B": "0.05"}, account B trades 0.05 lots and every other account
trades 0.01.
A common way to size by account balance is to compute each account's lots on your side and send them all as weights:
from decimal import Decimal, ROUND_DOWN
def lots_for(balance: Decimal, reference_balance: Decimal, reference_lots: Decimal) -> str:
raw = reference_lots * balance / reference_balance
lots = raw.quantize(Decimal("0.01"), rounding=ROUND_DOWN) # the symbol's volume step
return str(max(lots, Decimal("0.01"))) # at least the minimumUse Decimal (or your language's decimal type) and send strings. Round down
to the symbol's volume step — rounding up quietly increases every account's
risk. A weight that is not a usable volume is refused with 400
INVALID_REQUEST naming the account.
Bring the accounts online first
Each account's order can only be sent while that account is online, and a
broker login takes about ten seconds in our tests. A multi-account order brings offline
accounts online itself: with no executeAt, it waits for them — up to 60
seconds — and sends to everyone together as soon as they are all ready (or as
soon as the only ones left need a person, such as a wrong password). After the
minute, the barrier policy decides about the stragglers: skipped
(release-ready), waited for until expiresAt (wait), or the whole order
held (all-or-nothing).
When the moment matters — a signal whose price will not wait a minute — bring
the accounts online before you create the order, and wait until they are
ready:
session.post(f"{API}/v1/accounts/prepare", json={"accountIds": account_ids}) # up to 200 per call
# poll GET /v1/accounts/{id}/status until "ready" for each, as in the language guidesThen create the multi-account order with executeAt at the moment you want to
enter, or without it to send as soon as every account is ready. See
Signals → prepare. For
accounts that must act at any moment, the always_on mode keeps them online
all the time — see Accounts → Modes.
Barrier policies
The barrier is the moment the orders are released. barrierPolicy decides
what happens when some accounts are not online at that moment:
| Policy | Behaviour | Use it when |
|---|---|---|
release-ready | Send to the accounts that are online; skip the rest (skipped). | The entry is time-sensitive. A partial entry beats a late one. This is the default. |
all-or-nothing | Send to none unless every account is online. | The strategy only makes sense across the whole set — a hedge split across accounts, for example. |
wait | Keep waiting past executeAt until every account is online, up to expiresAt. | Coverage matters more than the price. |
all-or-nothing is about sending, not about the result. A broker can still
reject one account's order after all were sent — for margin, say — and no API
can make a hundred independent brokers fill as one transaction.
If expiresAt passes and the policy still will not send, the order ends
abandoned with nothing sent. With release-ready or wait, reaching
expiresAt sends to whichever accounts are ready.
Following it to the end
import time
def follow(wave_id, timeout=300):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
wave = session.get(f"{API}/v1/execution-waves/{wave_id}").json()["data"]
if wave["state"] in ("settled", "cancelled", "abandoned"):
return wave
time.sleep(1)
raise TimeoutError(wave_id)
wave = follow(wave["id"])
print(wave["state"], wave["summary"], f"spread {wave['dispatchSpreadMs']} ms")
for leg in wave["legs"]:
print(leg["accountId"], leg["state"], leg["volume"], leg["orderId"], leg["stateDetail"])The order moves planned → preparing → armed → releasing → settled: accepted,
bringing accounts online, waiting for the moment, sending, and every account has
a result. cancelled and abandoned mean nothing was sent.
{
"state": "settled",
"dispatchSpreadMs": 30,
"summary": { "total": 100, "filled": 97, "partiallyFilled": 1, "rejected": 2, "skipped": 0, "unresolved": 1 }
}partiallyFilled counts the accounts, among the filled ones, whose order
filled only in part. Each leg shows the same with partiallyFilled (true or
false) and filledVolume, how much its order filled.
A multi-account order never reports a single success or failure. Each leg has its own state:
| Leg state | Meaning | What to do |
|---|---|---|
filled | That account's order filled, fully or in part: the leg's filledVolume says how much, and partiallyFilled is true when it was only in part. | Fetch GET /v1/orders/{orderId} for its price. The position it opened is the order's brokerPositionId (until that is set, usually within a few minutes, the position carries the order's own ticket, brokerOrderId). |
rejected | The broker refused it. stateDetail says why. | Usually margin or a closed market on that account. Tell its owner. |
skipped | Never reached the broker: the account was not online at the barrier or not ready when the orders went out, trading is switched off for it or the workspace, the plan's allowance ran out or a payment is overdue, the broker had no price, the send provably failed, or the order was cancelled first. stateDetail says which. Also an unresolved account whose order the broker turned out to have no record of. | Retry the skipped accounts, if it still makes sense. |
unresolved | We do not know yet whether it executed. | Do not resend. Poll the order; we confirm it with the broker. Once we have, the account moves to filled, rejected or skipped, and a wave.updated webhook says so. |
pending, preparing, ready, dispatched | Still in progress. | Keep polling. |
Each leg's order is an ordinary order: it appears in GET /v1/orders, is
confirmed against the broker like any other, and is closed like any other —
with POST /v1/accounts/{id}/positions/{ticket}/close,
account by account, or all at once.
Scheduling with executeAt and expiresAt
{
"executeAt": "2026-10-01T07:00:00Z",
"expiresAt": "2026-10-01T07:00:30Z",
"barrierPolicy": "release-ready"
}The orders are sent at executeAt, not before — even if every account is
ready early. Bring the accounts online first, as above, so they are ready when
the moment comes. Times are RFC 3339; use UTC. A warm_on_demand account goes
offline after 15 idle minutes, so for an executeAt further ahead than that,
prepare the accounts shortly before it, or use always_on for them.
Cancelling
curl -sS -X POST "https://api.fxapis.com/v1/execution-waves/$WAVE_ID/cancel" \
-H "Authorization: Bearer $FXAPIS_KEY"Possible only before anything is sent: a scheduled order waiting for its
executeAt, or one waiting for accounts. Once orders are going out, cancelling
returns 409 WAVE_ALREADY_RELEASING — an order that has left for a broker
cannot be recalled, and cancelled would claim that nothing was sent.
Closing what it opened
curl -sS -X POST "https://api.fxapis.com/v1/execution-waves/$WAVE_ID/close" \
-H "Authorization: Bearer $FXAPIS_KEY" \
-H "Idempotency-Key: close:signal_9931"Closes, on every account at once, the position each filled account's order
opened: the order's brokerPositionId, or its brokerOrderId until that is
set. Each close is an ordinary close order, exactly like
closing one position: never refused
for your plan's allowance, refused while trading is switched off. Send
{"accountIds": ["…"]} to close only some accounts. It needs the
trading:reduce scope, and the order must have ended; before that it answers
409 WAVE_NOT_ENDED.
An Idempotency-Key is required. A repeated close does not close twice:
it opens opposing positions. Each account's close is keyed from yours, so
sending the same key again answers with the first result
(idempotent-replay: true) and sends nothing.
The answer has a result for each account:
result | Meaning |
|---|---|
closed | The broker closed it. closeOrderId is the close order. |
not_found | Already closed, or no such open position. |
rejected | The broker refused the close. detail says why. |
unresolved | Sent, and we are confirming the result with the broker. Do not resend; poll GET /v1/orders/{closeOrderId}. |
skipped | Nothing was sent: the account's order did not fill, the account is not online, or trading is switched off. detail says which. |
Retrying the accounts it did not reach
curl -sS -X POST "https://api.fxapis.com/v1/execution-waves/$WAVE_ID/retry" \
-H "Authorization: Bearer $FXAPIS_KEY" \
-H "Idempotency-Key: retry:signal_9931" \
-H "Content-Type: application/json" \
-d '{ "barrierPolicy": "release-ready" }'Places a new multi-account order for the accounts that were skipped: the
same market, side, per-account volumes, stops and comment. The body is
optional:
| Field | Meaning |
|---|---|
include | Which accounts to retry, by result: ["skipped"] (the default), or ["skipped", "rejected"] to retry the ones the broker refused too. |
accountIds | Only these accounts, of those include selects. |
barrierPolicy, executeAt, expiresAt | As for a new order. barrierPolicy defaults to the original's. |
A cancelled or abandoned order sent nothing, so every account is retried.
An unresolved account is never retried: it may have executed. The new order
is checked like any new one (plan, accounts, the key's limits, the rate limit),
and the two are linked: the new one's retryOf is the original, and the
original's retriedBy lists the new one. It needs an Idempotency-Key: the
same key again returns the same new order, 200 with
idempotent-replay: true. Before the original has ended it answers 409
WAVE_NOT_ENDED, and 409 NOTHING_TO_RETRY when no account matches.
Dispatch spread
dispatchSpreadMs is the time between the first account's order being sent
and the last. It is the number to watch: as it grows, your accounts are getting
different prices. Every multi-account order reports its own; these are from our
benchmark against an instant broker, so a real broker adds its own latency on top:
| Accounts | Spread |
|---|---|
| 10 | 17 ms |
| 50 | 26 ms |
| 100 | 30 ms |
Measured on our side against a broker answering instantly. Each broker adds its own latency on top — about 100 ms for a round trip to a London broker — and brokers fill at their own prices, so fills across accounts are close but never identical.
Retrying safely
Send an Idempotency-Key with every multi-account order. Sending the same key
again returns the order that key created — 200 with idempotent-replay: true
— rather than creating a second one, which would be a duplicate trade on every
account at once. Use one key per decision, such as wave:signal_9931. See
Idempotent MT5 orders.
Creating, retrying and cancelling multi-account orders has its own rate limit
per workspace — 10 a minute with a burst of 2 — separate from single orders and
reads (closing what one opened counts as trading, like any close); a 429
RATE_LIMITED carries retry-after. See Errors.
Related
- Build an MT5 trade copier — multi-account orders as the fan-out.
- Signals and click-to-trade — when each member approves their own trade instead.
- Trading → Multi-account orders and the API reference.
Broker compatibility
fxapis works with any MetaTrader 5 broker or prop firm, demo or live: what you need to connect an account, how the server search works, what differs from broker to broker, and why MT4 is not supported.
Copy trading
Coming soon: copy an MT5 master account's trades to follower accounts, managed by fxapis: sizing, symbol mapping, risk limits, an executions log, webhooks, and no code on your side. Today: build your own copier from multi-account orders.