# Place MetaTrader 5 orders from Node.js and TypeScript — no MetaTrader installed

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

> A complete TypeScript program for Node.js that connects an MT5 account, places a market order with an idempotency key, retries safely, reads positions and…



This guide builds one TypeScript program that trades a MetaTrader 5 account end
to end: connect the account, bring it online, place a market order, read the
position, close it, take the account offline. It needs nothing but Node.js —
the built-in `fetch` does all the work, and there is no MetaTrader terminal on
your side. The terminal runs in our cloud; your code only makes HTTPS requests.

The same code runs in plain JavaScript if you delete the type annotations, and
in any runtime with a standard `fetch`: Bun, Deno, a serverless function. Keep
it on your server, though — the API key must never reach a browser.

<Callout type="warn" title="Use a demo account">
  `fx_test_` keys are a label, not a sandbox — every key reaches a real broker. Run this with a
  **broker demo account** until you are sure your code does what you expect.
</Callout>

## What you need [#what-you-need]

* Node.js 24 or newer, which runs `.ts` files directly. On Node.js 20 or 22, run it with
  `npx tsx trade.ts` instead.
* An fxapis API key with the `accounts:write`, `trading:execute` and `trading:read` scopes
  ([create one in the console](https://fxapis.com/dashboard/keys)).
* An MT5 **demo** account: its login number, its **trading** password (not the investor password),
  and the server name exactly as MetaTrader shows it. See
  [Connect MT5 accounts](/guides/connect-accounts) if you are unsure which is which.

## Set up [#set-up]

```bash
mkdir mt5-trade && cd mt5-trade
npm init -y && npm pkg set type=module
npm install --save-dev typescript @types/node   # only for type checking

export FXAPIS_KEY="fx_test_…"          # your API key
export MT5_LOGIN="26177561"
export MT5_SERVER="VantageMarkets-Demo"
export MT5_PASSWORD="…"                # the trading password
export MT5_SYMBOL="EURUSD"             # as your broker names it
```

There is no runtime dependency to install. Keep the key and the password in
environment variables or a secret store — never in source code or a log line.

## The complete program [#the-complete-program]

Save this as `trade.ts` and run `node trade.ts`.

```ts title="trade.ts"
// Connect an MT5 account through fxapis, open a small trade, and close it.
// Run it against a broker DEMO account. Every fxapis key reaches a real broker.
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";

const API = process.env.FXAPIS_API ?? "https://api.fxapis.com";
const KEY = required("FXAPIS_KEY");
const NEEDS_ATTENTION = new Set(["invalid_credentials", "trading_disabled", "needs_2fa", "needs_certificate"]);
// Codes that mean "nothing was placed yet — ask again with the SAME key".
const RETRY_SAME_KEY = new Set(["SEND_FAILED", "IDEMPOTENCY_IN_FLIGHT", "ACCOUNT_NOT_READY", "NO_RUNTIME", "RATE_LIMITED"]);

type Account = { id: string; login: string; server: string; state: string };
type Status = { id: string; state: string; detail: string | null };
type Order = {
  id: string;
  state: string;
  symbol: string;
  filledPrice: string | null;
  brokerPositionId: string | null;
};
type Position = {
  brokerPositionId: string;
  symbol: string;
  side: "buy" | "sell";
  volume: string | null;
  openPrice: string | null;
  profit: string | null;
  observedAt: string;
};
type ApiErrorBody = { code: string; message: string; details?: Array<Record<string, unknown>> };

class ApiError extends Error {
  status: number;
  code: string;
  details: Array<Record<string, unknown>>;
  constructor(status: number, body: ApiErrorBody) {
    super(`${status} ${body.code}: ${body.message}`);
    this.status = status;
    this.code = body.code;
    this.details = body.details ?? [];
  }
}

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Set ${name}`);
  return value;
}

async function call(method: string, path: string, body?: unknown, headers: Record<string, string> = {}, timeoutMs = 30_000) {
  // Content-Type only with a body.
  const json: Record<string, string> = body === undefined ? {} : { "Content-Type": "application/json" };
  return fetch(`${API}${path}`, {
    method,
    headers: { Authorization: `Bearer ${KEY}`, ...json, ...headers },
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(timeoutMs),
  });
}

/** The error object of a failed response, or null if the body is not ours (a proxy page). */
async function errorOf(response: Response): Promise<ApiErrorBody | null> {
  try {
    const parsed = (await response.json()) as { error?: ApiErrorBody };
    return parsed.error ?? null;
  } catch {
    return null;
  }
}

async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
  const response = await call(method, path, body);
  if (!response.ok) {
    const error = await errorOf(response);
    throw new ApiError(response.status, error ?? { code: "HTTP_ERROR", message: response.statusText });
  }
  return ((await response.json()) as { data: T }).data;
}

/** Stores the account at fxapis. It does not log in yet. */
async function connectAccount(login: string, server: string, password: string): Promise<Account> {
  try {
    return await request<Account>("POST", "/v1/accounts", {
      login,
      server,
      password,
      mode: "warm_on_demand",
      label: "node-guide",
    });
  } catch (error) {
    if (!(error instanceof ApiError) || error.code !== "ACCOUNT_EXISTS") throw error;
    // Connected on an earlier run: find it instead of connecting it twice.
    const accounts = await request<Account[]>("GET", "/v1/accounts");
    const existing = accounts.find((a) => a.login === login && a.server === server);
    if (!existing) throw error;
    return existing;
  }
}

/** Starts the broker login, then polls until the account is ready to trade. */
async function bringOnline(accountId: string, timeoutMs = 120_000): Promise<Status> {
  await request("POST", `/v1/accounts/${accountId}/warm`); // 409 NEEDS_OPERATOR / NO_SECRET throws
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const status = await request<Status>("GET", `/v1/accounts/${accountId}/status`);
    if (status.state === "ready") return status;
    if (NEEDS_ATTENTION.has(status.state)) {
      // Not retryable. Repeated failed logins are how brokers lock accounts.
      throw new Error(`account needs attention: ${status.state} (${status.detail})`);
    }
    await sleep(2_000);
  }
  throw new Error(`account not ready after ${timeoutMs / 1000}s`);
}

/** After ORDER_UNRESOLVED: never resend. Poll the order until the broker's record settles it. */
async function waitForOutcome(orderId: string, timeoutMs = 300_000): Promise<Order> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const order = await request<Order>("GET", `/v1/orders/${orderId}`);
    if (order.state !== "unknown") return order;
    await sleep(5_000);
  }
  throw new Error(`order ${orderId} is still unknown. Do not resend it; check it again later.`);
}

/** Places an order (or a close) exactly once, however many times the request is retried. */
async function sendOrder(path: string, body: Record<string, unknown>, attempts = 6): Promise<Order> {
  const key = randomUUID(); // one key per order, reused on every retry
  for (let attempt = 0; attempt < attempts; attempt++) {
    const backoff = Math.min(2 ** attempt, 10) * 1000;
    let response: Response;
    try {
      response = await call("POST", path, body, { "Idempotency-Key": key }, 90_000);
    } catch {
      await sleep(backoff); // we never saw the answer; the same key makes asking again safe
      continue;
    }

    if (response.status === 201) return ((await response.json()) as { data: Order }).data;

    const error = await errorOf(response);
    if (!error && response.status >= 500) {
      await sleep(backoff); // not our error body: a gateway hiccup
      continue;
    }
    if (!error) throw new ApiError(response.status, { code: "HTTP_ERROR", message: response.statusText });

    if (error.code === "ORDER_UNRESOLVED") {
      return waitForOutcome(String(error.details?.[0]?.orderId));
    }
    if (RETRY_SAME_KEY.has(error.code)) {
      const retryAfter = Number(response.headers.get("retry-after") ?? 0) * 1000;
      await sleep(retryAfter || backoff);
      continue;
    }
    throw new ApiError(response.status, error); // ORDER_REJECTED, INVALID_REQUEST, QUOTA_EXCEEDED, …
  }
  throw new Error(`gave up after ${attempts} attempts; retrying later with key ${key} is still safe`);
}

async function main(): Promise<number> {
  const login = required("MT5_LOGIN");
  const server = required("MT5_SERVER");
  const symbol = process.env.MT5_SYMBOL ?? "EURUSD";

  const account = await connectAccount(login, server, required("MT5_PASSWORD"));
  console.log(`account ${account.id} (${account.state})`);

  await bringOnline(account.id);
  console.log("online");

  let order: Order;
  try {
    order = await sendOrder(`/v1/accounts/${account.id}/orders/market`, { symbol, side: "buy", volume: "0.01" });
  } catch (error) {
    if (error instanceof ApiError && error.code === "ORDER_REJECTED") {
      console.log(`rejected by the broker: ${error.message}`);
      return 1;
    }
    throw error;
  }
  console.log(`order ${order.id}: ${order.state} at ${order.filledPrice}`);
  if (order.state !== "filled" && order.state !== "partially_filled") return 1;

  // Positions refresh on their own about every 15 s; reconcile (optional) refreshes them now.
  await request("POST", `/v1/accounts/${account.id}/reconcile`);
  const positions = await request<Position[]>("GET", `/v1/accounts/${account.id}/positions`);
  for (const p of positions) {
    console.log(`  #${p.brokerPositionId} ${p.side} ${p.volume} ${p.symbol} @ ${p.openPrice} profit ${p.profit} (seen ${p.observedAt})`);
  }

  const close = await sendOrder(`/v1/accounts/${account.id}/positions/${order.brokerPositionId}/close`, {});
  console.log(`closed: ${close.state} at ${close.filledPrice}`);

  // Offline now rather than after the idle window. The stored password is kept.
  await request("POST", `/v1/accounts/${account.id}/cool`);

  if (process.env.FXAPIS_DISCONNECT === "1") {
    // Erases the stored password for good. Only when you are done with this account.
    const result = await request<{ credentialsRemoved: boolean }>("POST", `/v1/accounts/${account.id}/disconnect`);
    console.log(`disconnected, credentials removed: ${result.credentialsRemoved}`);
  }
  return 0;
}

process.exitCode = await main();
```

A run against a demo account prints something like:

```
account 0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51 (created)
online
order b1c7…9e2a: filled at 1.13426
  #2109584418 buy 0.01 EURUSD @ 1.13426 profit -0.07 (seen 2026-09-30T10:14:03.512Z)
closed: filled at 1.13419
```

## Authentication and the request helper [#authentication-and-the-request-helper]

`call` adds `Authorization: Bearer <key>` to every request and gives each one a
timeout with `AbortSignal.timeout`. `request` unwraps the `{ "data": … }`
envelope on success and throws an `ApiError` carrying the stable `code` on
failure — match on `code`, never on `message`. See
[Authentication](/authentication) for scopes and rotation, and
[Errors](/errors) for every code.

## Connecting the account [#connecting-the-account]

`POST /v1/accounts` stores the account and encrypts its password. It does not
log in, so it answers at once with the account in `created`. Keep the returned
`id` against your user; never keep the password.

A login and server can be connected once per workspace, so the second time you
run the program it gets **409** `ACCOUNT_EXISTS` and looks the account up with
`GET /v1/accounts` instead. Reference:
[Connect an MT5 account](/api-reference/accounts/connect-an-mt5-account).

## Bringing it online [#bringing-it-online]

`POST /v1/accounts/{id}/warm` answers **202** straight away while the broker
login happens, and `bringOnline` polls `GET /v1/accounts/{id}/status` every two
seconds until `ready` — usually about ten seconds in our tests.

It stops at once on a needs-attention state (`invalid_credentials`,
`trading_disabled`, `needs_2fa`, `needs_certificate`): those need a person, not
a retry. `warm` also answers **409** for an account already in one, so a wrong
password is never hammered against the broker. All states are listed in
[Accounts](/accounts#states).

## Placing the order safely [#placing-the-order-safely]

`sendOrder` is the function to reuse. It generates **one**
`Idempotency-Key` with `crypto.randomUUID()` and sends the same key on every
retry. Whatever happens on the network, the order is placed at most once; keys
are remembered for 24 hours.

Volumes and prices are strings — `"0.01"`, not `0.01`. JavaScript numbers are
doubles, so if you compute a size, do it with integers of the lot step or a
decimal library and send the string.

| Response                                  | What it means                                                     | What `sendOrder` does                                                                                   |
| ----------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **201**                                   | The broker answered; `state` is `filled` (or `partially_filled`). | Returns the order.                                                                                      |
| **422** `ORDER_REJECTED`                  | The broker refused it. Nothing executed.                          | Throws. If `details[0].retryable` is true (the price moved), a *new* decision with a *new* key is fine. |
| **409** `ACCOUNT_NOT_READY`, `NO_RUNTIME` | The account was not online in time. Nothing was sent.             | Retries with the same key.                                                                              |
| **409** `IDEMPOTENCY_IN_FLIGHT`           | The first attempt with this key is still running.                 | Retries with the same key.                                                                              |
| **502** `SEND_FAILED`                     | Proven never to have reached the broker.                          | Retries with the same key.                                                                              |
| **502** `ORDER_UNRESOLVED`                | We do not know yet whether it executed.                           | **Never resends.** Polls `GET /v1/orders/{id}` until it leaves `unknown`.                               |
| **429** `RATE_LIMITED`                    | Too many requests of this kind.                                   | Waits `retry-after` seconds, same key.                                                                  |
| Timeout or network error                  | You did not see the answer.                                       | Retries with the same key; the server replays the first answer.                                         |

`ORDER_UNRESOLVED` is the case that matters: the order may be live at the
broker, and resending it is how an account ends up with two positions. We
confirm it against the broker's records automatically and the order moves to
what really happened. More in [Idempotent MT5 orders](/guides/idempotency).

The order call has a 90-second timeout because it waits for the broker's
answer — and, for an offline account, for the login first.

## Reading positions [#reading-positions]

`GET /v1/accounts/{id}/positions` returns each open position with
`brokerPositionId`, `side`, `volume`, `openPrice`, `currentPrice`, `stopLoss`,
`takeProfit`, `profit` and `observedAt`. Positions refresh about every 15
seconds while the account is online; `observedAt` tells you how fresh a row is.
`POST /v1/accounts/{id}/reconcile` refreshes them immediately. Reference:
[Positions on an account](/api-reference/trading/positions-on-an-account).

## Closing the position [#closing-the-position]

`POST /v1/accounts/{id}/positions/{ticket}/close` with `{}` closes the whole
position; `{ "volume": "0.005" }` closes part. The ticket is
`brokerPositionId`, which the order response already carries. The close goes
through `sendOrder` with its own key — a repeated close on a hedging account
would open an opposite position rather than close twice.

A close of a position opened moments ago refreshes positions first;
`POSITION_NOT_FOUND` means it is already closed. The program's `reconcile`
call after the fill is optional — it only makes the positions listing current
at once.

## Offline and disconnect [#offline-and-disconnect]

`POST /cool` takes the account offline and keeps the stored password for next
time. Left alone, a `warm_on_demand` account goes offline after 15 idle
minutes. Stops and take profits live at the broker and keep working.

`POST /disconnect` erases the stored password for good — the call to make when
a customer leaves. The program makes it only with `FXAPIS_DISCONNECT=1`,
so you can run it repeatedly. Connecting a disconnected login again brings the same account back, with its history.

## Next steps [#next-steps]

* [Signals and click-to-trade](/signals) — bring accounts online the moment a member opens a signal.
* [One order across many accounts](/guides/multi-account-orders) — the same trade on up to 500 accounts on Enterprise (Starter 10, Scale 100).
* [TradingView alerts](/guides/tradingview) — place orders from a TradingView alert.
* [Errors](/errors) and the [API reference](/api-reference).
