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
.tsfiles directly. On Node.js 20 or 22, run it withnpx tsx trade.tsinstead. - An fxapis API key with the
accounts:write,trading:executeandtrading:readscopes (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 itThere 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.
// 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.13419Authentication 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.
| 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.
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
- Signals and click-to-trade — bring accounts online the moment a member opens a signal.
- One order across many accounts — the same trade on up to 500 accounts on Enterprise (Starter 10, Scale 100).
- TradingView alerts — place orders from a TradingView alert.
- Errors and the API reference.
Place MetaTrader 5 orders from Python — no MetaTrader installed
A complete Python program that connects an MT5 account, places a market order with an idempotency key, handles every failure correctly, reads positions and closes the trade — over plain HTTP with requests, on Linux, macOS or Windows.
Place MetaTrader 5 orders from PHP — no MetaTrader installed
A complete PHP program using Guzzle that connects an MT5 account, places a market order with an idempotency key, handles SEND_FAILED and ORDER_UNRESOLVED correctly, reads positions and closes the trade — from any PHP host, with no terminal.