Docs

Trading and history

Orders, closes, pending orders, execution waves, and the history they leave.

Placing orders, and the durable record they leave.

If you read one section, read The three failures. It is the difference between a client that retries safely and one that occasionally opens two positions where you asked for one.

Placing a market order

curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/orders/market" \
  -H "Authorization: Bearer $FXAPIS_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "symbol": "EURUSD",
    "side": "buy",
    "volume": "0.10",
    "stopLoss": "1.0890",
    "takeProfit": "1.1010",
    "deviationPoints": 20,
    "clientOrderId": "strategy-a-0417"
  }'

The account must be ready. See the lifecycle for getting it there.

Volumes and prices are strings

"0.10", not 0.1. This is enforced — a JSON number is rejected with INVALID_REQUEST, not quietly accepted.

A JSON number is an IEEE double. 0.1 + 0.2 is 0.30000000000000004, and a volume that has been through a double has already lost precision we cannot recover. Accepting it would mean silently trading a size you did not ask for, so the API refuses it while you can still fix it.

Use a decimal type on your side, and serialise it as a string.

Stops are absolute prices

stopLoss: "1.0890" is a price, not a distance in pips. We check the obvious error — a buy whose stop loss sits above the entry — because the broker cannot: its distance rules pass such an order, since only you and we know you meant to buy.

Distance and step rules are the broker's, and they are enforced by their order_check before anything is sent.

Always send an Idempotency-Key

Idempotency-Key: 8f14e45f-ea8d-4b1e-9c2a-7b3d5e9f0a11

Without one, a request that times out leaves you stuck. You cannot tell whether the order was placed; retrying risks a second position, and not retrying risks none at all. With one, retrying is free.

SituationWhat you get
Same key, same body, first call finishedThe same response, replayed, with idempotent-replay: true
Same key, same body, first call still running409 IDEMPOTENCY_IN_FLIGHT — retry shortly
Same key, different body409 IDEMPOTENCY_KEY_REUSED
Same key, after a SEND_FAILEDA genuine new attempt — nothing reached the broker, so the key is released

That last row is the subtle one, and it is deliberate. A key is only released when we know the broker never saw the order. If the outcome is unresolved, the key stays claimed and a retry replays the unresolved answer rather than placing a second order.

Keys are honoured for 24 hours and scoped to your tenant. Use a fresh one per order — a UUID is ideal.

The three failures

An order can fail in three ways, and the response tells you which.

SEND_FAILED (502) — it never reached the broker

{"error":{"code":"SEND_FAILED","message":"The terminal could not be reached, and the order was never sent. It is safe to retry.",
 "details":[{"orderId":"...","state":"failed","retryable":true}]}}

We have proof: the connection to the terminal was refused, or it told us explicitly that it sent nothing. The order is failed.

Retry it. That is the only order state where resending is safe.

ORDER_REJECTED (422) — the broker said no

{"error":{"code":"ORDER_REJECTED","message":"The broker rejected the order: No money",
 "details":[{"orderId":"...","state":"rejected","retryable":false}]}}

The broker refused it, with a reason, and nothing executed. The order carries the broker's own retcode — fetch it with GET /v1/orders/{id} and you will see 10019 here, unmodified.

retryable tells you whether the reason was transient:

  • retryable: true — a requote (10004), a moved price (10020), no quotes right now (10021). The market moved; sending a new order is correct. Look at the new price first.
  • retryable: false — insufficient margin (10019), market closed (10018), trading disabled (10017), an invalid volume or stop. Retrying collects the same rejection.

ORDER_UNRESOLVED (502) — we do not know

{"error":{"code":"ORDER_UNRESOLVED","message":"The terminal did not answer, and we do not know whether the order reached the broker. It is being reconciled. Do not resend it.",
 "details":[{"orderId":"...","state":"unknown","retryable":false}]}}

Do not resend this order. It may be live at the broker right now.

This happens when a send times out, the connection drops mid-request, or the broker returns a code that does not say what happened — 10012 TIMEOUT cancels our wait, not necessarily the order.

Poll GET /v1/orders/{id}. Reconciliation moves the order out of unknown to whatever actually happened; needsReconciliation is true for exactly as long as that is outstanding. See Reconciliation below.

We are deliberately pessimistic here. An outcome we cannot confirm is unknown, never "probably failed" — including for a return code we have never seen before. Being wrong in this direction costs a reconciliation; being wrong in the other direction costs a position you never asked for.

Reconciliation

An order in unknown is settled by asking the broker what it actually recorded. This runs on a schedule; POST /v1/accounts/{id}/reconcile runs it immediately for one account.

curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/reconcile" -H "Authorization: Bearer $FXAPIS_KEY"
{"data":{"examined":1,"resolved":1,"stillUnknown":0,"ambiguous":0,
         "dealsIngested":1,"positionsOpen":1,"positionsClosed":0,"skipped":null}}

Safe to call at any time, and safe to call twice.

How an order is matched

Three signals, in order of trust.

The magic number. MetaTrader's magic is an integer on every order and deal, meant as the caller's own reference, and brokers leave it alone. Ours is derived from the order id. This is the strongest signal and it is tried first.

The comment tag. ow: plus eight characters of the order id, which the broker echoes onto the deals the order produces. The tag always goes first in the field, so your own comment never displaces it — MT5 keeps 31 characters and yours takes what is left.

Symbol, direction and time. The fallback, used only when neither of the above survived.

Why three: brokers do rewrite the comment, and they do it particularly on stop-loss, take-profit and margin-call closes — which is exactly when reconciliation matters. magic survives that.

When it will not decide

If two deals could both be your order — the same strategy firing twice on one account, with the tag lost — the order stays in unknown and is counted in ambiguous.

That is deliberate. Attaching a fill to the wrong order produces a history that looks settled and is wrong, which is worse than the gap it replaced. Those orders need a person:

curl -sS "$FXAPIS_API/v1/orders?state=unknown" -H "Authorization: Bearer $FXAPIS_KEY"

The order's stateDetail says how many candidates there were.

What it resolves to

Broker historyOrder becomes
Deals covering the full volumefilled
Deals covering part of itpartially_filled
No matching deal, and enough time has passedrejected
No matching deal yet, within the grace periodstays unknown
More than one possible matchstays unknown, counted as ambiguous

Reconciliation never resolves an order to failed. That state claims the request never left our process, and reconciliation is not in a position to know — it can only see whether a deal resulted. An order with no deal did not open a position, which is what rejected records.

It also fills in your history

Every reconciliation writes down the broker's deals for the window, whether or not anything needed resolving — including deals no order of yours caused. Pulling history only when something went wrong would leave it full of holes shaped like the times nothing went wrong.

Positions are refreshed at the same time. A position the broker stops reporting is marked closed rather than deleted, because a position that existed and is now gone is a fact worth keeping.

Closing a position

curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/positions/2104151615/close" \
  -H "Authorization: Bearer $FXAPIS_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" -d '{"volume": "0.05"}'

Omit volume to close the whole thing.

An idempotency key matters more here than on an opening order. A retried close does not close twice — on a hedging account it opens an opposing position, and the account ends up with exposure nobody chose.

A close is an order. It gets its own record with intent: "close", the same three failure modes, and the same rule: ORDER_UNRESOLVED means do not resend. An unresolved close is the case where you might believe you are flat and not be.

Two things are refused before anything is sent:

  • Closing more than the position holds. The message names both numbers.
  • A partial close that would leave less than the symbol's minimum behind — the broker refuses that after the close, leaving a position too small to trade and too small to close.

Moving a stop

curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/positions/2104151615/modify" \
  -H "Authorization: Bearer $FXAPIS_KEY" -H "Content-Type: application/json" \
  -d '{"stopLoss": "1.0890", "takeProfit": null}'

null removes a level. Omitting the field leaves it unchanged. An omitted field silently clearing a stop is how protection disappears without anybody choosing it.

Recorded with intent: "modify", because widening a stop changes what the account risks and belongs beside the trade it protects — a stop that moved before a loss is exactly what an audit asks about.

A stop on the winning side of the entry is refused here. The broker's distance rules would pass it, because only you and we know the position is long.

Pending orders

curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/orders/pending" \
  -H "Authorization: Bearer $FXAPIS_KEY" -H "Content-Type: application/json" \
  -d '{"symbol":"EURUSD","side":"buy","kind":"limit","volume":"0.10","price":"1.0900"}'
KindBuy waitsSell waits
limitbelow the marketabove it
stopabove the marketbelow it
stop_limitstop triggers, then places a limitsame

A limit waits for a better price; a stop waits for a worse one and is how a breakout is traded. The wrong side is refused before anything is sent, with a message naming the order you probably meant — the broker's own INVALID_PRICE says nothing about which way round it should have been.

A placed pending order is working, not filled. That distinction is real money: filled means a position exists. Some brokers answer a pending placement with DONE, the same code a fill returns, and reading that as a fill would tell you you are in the market when you are waiting to be.

Cancel one with POST /v1/orders/{id}/cancel. If it already triggered the broker refuses with 10035 — at that point there is a position, not an order, and what you want is a close. A successful cancel moves the pending order to cancelled and records the instruction itself as completed.

Margin and profit, before you trade

curl -sS -X POST "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/calculate" \
  -H "Authorization: Bearer $FXAPIS_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"margin","symbol":"EURUSD","side":"buy","volume":"0.10","price":"1.1370"}'
# → { "data": { "kind": "margin", "value": "22.74000000" } }

Opens nothing and is safe to call freely. This is how you find out an order will be refused for margin before sending it, rather than collecting a 10019. "kind": "profit" needs a closePrice.

A null value means the broker would not answer, which is not the same as zero.

Order states

StateMeaningResend?
acceptedTaken, not yet sent—
sendingIn flightNo
workingA pending order waiting at the broker. No position exists.No — it is already there
completedAn instruction that is not a trade finished: a cancel, a stop moved—
filledThe broker confirmed the full volumeNo
partially_filledPart filled; the rest may still fillNo
rejectedThe broker refused it. Nothing executed.Only if retryable
failedProven never to have reached the brokerYes
unknownOutcome unestablishedNever
cancelledWithdrawn before execution—
expiredA pending order lapsed at the broker—

failed and unknown are never interchangeable. failed is a claim we make only with proof.

History

Three tables, because the questions are different.

Orders — what you asked for

curl -sS "$FXAPIS_API/v1/orders?state=unknown&limit=50" -H "Authorization: Bearer $FXAPIS_KEY"

Every order, with the broker's return code preserved exactly as given. Filter by accountId, state, symbol, and a since/until window.

state=unknown is the query worth building an alert on: it is everything whose outcome is not yet established.

Deals — what the broker actually did

curl -sS "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/deals?since=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer $FXAPIS_KEY"

A deal is the broker's record of money changing hands. This is what a statement reconciles against, and it is not one row per order:

  • One order can produce several deals. A partial fill leaves more than one.
  • A deal can exist with no order of yours behind it. A stop loss firing at the broker, a swap charge, a deposit, a correction, or somebody trading the same login from the MetaTrader desktop.

That second point is why this endpoint exists separately. A history containing only your own orders would show an account whose balance moves for no visible reason. orderId is null on exactly those rows, so you can tell them apart.

Ordered by the broker's own timestamp, not ours. dealType is named rather than numeric — balance, commission, buy — because 2 means nothing on a statement.

Deals for one order: GET /v1/orders/{id}/deals.

Positions — what is open now

curl -sS "$FXAPIS_API/v1/accounts/$FXAPIS_ACCOUNT/positions" -H "Authorization: Bearer $FXAPIS_KEY"

A snapshot with observedAt saying when it was true. The broker is authoritative; this is what we last saw, and the timestamp is there so you can tell a current view from a stale one rather than assuming.

Pagination

Cursor, not offset:

curl -sS "$FXAPIS_API/v1/orders?limit=100" -H "Authorization: Bearer $FXAPIS_KEY"
# → { "data": [...], "page": { "hasMore": true, "nextCursor": "2026-09-28T14:02:11.482Z" } }

curl -sS "$FXAPIS_API/v1/orders?limit=100&cursor=2026-09-28T14:02:11.482Z" -H "Authorization: Bearer $FXAPIS_KEY"

An offset shifts as new orders arrive, so page two would silently skip rows — in a trading history, exactly the rows added while you were paginating.

Retention

Orders and deals are kept for the life of the account and are not pruned. Disconnecting an account erases its credentials; it does not erase its history, because that history is what a dispute or an audit is settled with.

A worked client

import uuid, time, httpx

class OrderError(Exception): ...
class Unresolved(OrderError):
    """Do not resend. The order may be live."""

def place(client, account_id, **order):
    key = str(uuid.uuid4())

    for attempt in range(3):
        response = client.post(
            f"/v1/accounts/{account_id}/orders/market",
            json=order,
            # The SAME key on every attempt. That is the whole point: the
            # server decides whether this is a retry or a new order, and it is
            # the only party that can know.
            headers={"Idempotency-Key": key},
        )

        if response.status_code == 201:
            return response.json()["data"]

        error = response.json()["error"]
        code = error["code"]

        if code == "SEND_FAILED":
            # Proven not to have reached the broker.
            time.sleep(2 ** attempt)
            continue

        if code == "IDEMPOTENCY_IN_FLIGHT":
            time.sleep(1)
            continue

        if code == "ORDER_UNRESOLVED":
            # Never retried. Hand the order id to whatever watches for
            # reconciliation and stop.
            raise Unresolved(error["details"][0]["orderId"])

        if code == "ORDER_REJECTED":
            if error["details"][0].get("retryable"):
                # The market moved. A real client re-decides the price here
                # rather than blindly resending.
                time.sleep(0.5)
                continue
            raise OrderError(error["message"])

        raise OrderError(f"{code}: {error['message']}")

    raise OrderError("gave up after 3 attempts")

Execution waves

One instruction, many accounts, one moment.

curl -sS -X POST "$FXAPIS_API/v1/execution-waves" \
  -H "Authorization: Bearer $FXAPIS_KEY" -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" -d '{
    "accountIds": ["…", "…", "…"],
    "symbol": "EURUSD", "side": "buy", "volume": "0.01",
    "weights": {"<account-id>": "0.05"},
    "barrierPolicy": "release-ready",
    "executeAt": "2026-10-01T13:30:00Z"
  }'

Terminals are warmed first, the wave waits at a barrier, then every leg is dispatched at once. Poll GET /v1/execution-waves/{id}.

A wave is not atomic

There is no transaction across a hundred brokers. Legs fill at different prices, some are rejected for margin, some go unanswered.

So a wave never reports success or failure. It settles, and summary counts what landed:

{"state":"settled","dispatchSpreadMs":30,
 "summary":{"total":100,"filled":97,"rejected":2,"skipped":0,"unresolved":1}}

Each leg carries its own orderId. A leg that filled is an ordinary order — reconciled, audited and queried like any other. A wave schedules orders; it is not a second kind of order with its own rules.

unresolved legs mean the same as an unresolved order: do not resend, reconciliation owns them.

Barrier policies

PolicyWhen some accounts are not ready
release-readyFire the ready ones, skip the rest. A partial entry beats a late one.
all-or-nothingSend none. The strategy only makes sense applied across the whole set.
waitHold past executeAt until everyone is ready, up to expiresAt.

all-or-nothing is about dispatch, not outcome. A broker can still reject a leg after it was sent; nothing can prevent that.

The barrier releases early when every account is ready, whatever the policy — waiting for a clock with nothing left to wait for only widens the spread.

Dispatch spread

dispatchSpreadMs is the gap between the first leg leaving and the last. It is the number worth alerting on: a wave whose spread grows is a wave whose accounts are getting different prices, which a customer experiences as slippage they did not cause.

Measured against an instant broker, so it reflects our own contribution:

accountsspread
1017 ms
5026 ms
10030 ms

Real brokers add their own latency on top.

Calling one off

POST /v1/execution-waves/{id}/cancel, but only while nothing has been dispatched. Once legs are going out there is no cancelling them — cancelled would claim nothing was sent.

On this page