Docs

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

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 closes the trade using the built-in fetch — no SDK, no terminal, no Windows.

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.

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.

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).
  • 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 if you are unsure which is which.

Set up

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

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

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

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 for scopes and rotation, and Errors for every code.

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.

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.

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.

ResponseWhat it meansWhat sendOrder does
201The broker answered; state is filled (or partially_filled).Returns the order.
422 ORDER_REJECTEDThe 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_RUNTIMEThe account was not online in time. Nothing was sent.Retries with the same key.
409 IDEMPOTENCY_IN_FLIGHTThe first attempt with this key is still running.Retries with the same key.
502 SEND_FAILEDProven never to have reached the broker.Retries with the same key.
502 ORDER_UNRESOLVEDWe do not know yet whether it executed.Never resends. Polls GET /v1/orders/{id} until it leaves unknown.
429 RATE_LIMITEDToo many requests of this kind.Waits retry-after seconds, same key.
Timeout or network errorYou 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.

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

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.

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

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

On this page