npm install metatrader5 returns nothing useful. There is no official package,
no community package that actually talks to a broker, and every StackOverflow
thread ends with "run the Python library on a Windows VPS and call it from
Node". If your backend is TypeScript, that's the whole story of the MT5 API in
Node.js: a sidecar in another language on another operating system.
This post is the way around it. The Python version
of this tutorial exists; this is the same flow in TypeScript with nothing but
Node 18's built-in fetch.
Why there's no MetaTrader 5 npm package
The official MetaTrader5 Python package isn't a client library in the normal
sense. It doesn't speak a network protocol to the broker. It attaches to a
MetaTrader 5 terminal process running on the same Windows machine and asks
that for data, the same way a COM automation script drives Excel. No
terminal, no data. Wrong OS, no terminal.
So there's nothing for a Node package to bind to. The projects that exist on npm are wrappers around one of three hacks: an Expert Advisor that writes files or opens a socket, a ZeroMQ bridge compiled into the terminal, or an HTTP shim around the Python package. All of them still need a Windows terminal somewhere, logged in, awake, and not mid-update.
The REST approach moves the terminal to someone else's problem. MetaKit runs one isolated terminal per connected account (MT5 under Wine in a Linux container) and exposes it over HTTP. From Node you make requests. That's it. The honest cost is a network hop on every read, which matters if you're building a latency-sensitive execution engine and doesn't if you're building anything else.
Prerequisites
- Node 18 or newer, so
fetchis global and there's noaxiosto install. - An MT5 login number, password, and server name. The investor password is
enough for read-only access; the master password is needed for a
fullaccount that can be a copier follower. - A MetaKit API key from the dashboard under Settings, API keys. A
readonlykey can't create accounts, so you'll need afullkey for the connect step.
export METAKIT_KEY="stk_live_..."
export MT5_PASSWORD="..."Connecting an account occupies a slot of the matching tier (readonly or
full), so you need a free one before the POST succeeds. If there isn't one,
the create call fails with a 402 no_slots_available and nothing else
happens; buy a slot in the dashboard under Billing, or via POST /v1/slots
if you've saved a card, and try again.
Every example below uses one small helper that centralises auth and error handling. Put it at the top of the file:
const BASE = "https://api.metakit.cloud";
class MetaKitError extends Error {
constructor(public status: number, public code: string, message: string) {
super(message);
}
}
async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.METAKIT_KEY}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) {
const body = (await res.json()) as { error: { code: string; message: string } };
throw new MetaKitError(res.status, body.error.code, body.error.message);
}
return (await res.json()) as T;
}Step 1: find the broker id
Server names are matched exactly, and the API needs a numeric broker_id
alongside the server string. Search rather than guess:
type Broker = { id: number; name: string };
type Page<T> = { data: T[]; page: number; limit: number; total: number; total_pages: number };
const brokers = await api<Page<Broker>>(`/v1/brokers?q=${encodeURIComponent("IC Markets")}`);
for (const b of brokers.data) console.log(b.id, b.name);Keep the id. If your broker isn't listed, [email protected] adds them;
more than three hundred are already there.
Step 2: connect the account
type AccountStatus =
| "provisioning" | "starting" | "connected"
| "error" | "invalid_credentials" | "disconnected";
type Account = {
id: number;
status: AccountStatus;
currency: string | null;
balance: number | null;
equity: number | null;
free_margin: number | null;
open_trades: number;
};
const account = await api<Account>("/v1/accounts", {
method: "POST",
body: JSON.stringify({
name: "Main",
number: 40317,
password: process.env.MT5_PASSWORD,
broker_id: 210,
server: "ICMarketsSC-Demo",
type: "full",
}),
});
console.log(account.id, account.status); // 2 provisioningThe request returns in well under a second. The account is not usable yet. What just happened is that a terminal was scheduled to boot and log in on your behalf, and that takes real time.
Step 3: poll until the account is connected
Here's the honest part. A first connect takes about 30 seconds on a common
broker and can take several minutes on a white-label broker that needs its
own terminal image built. During that window status is provisioning or
starting, both of which mean "still working". Data endpoints return a 502
upstream_error until connected.
The bug everyone writes once is if (status !== "connected") throw. It fires
on every fresh connect. Wait for a terminal status instead:
const TERMINAL = new Set<AccountStatus>([
"connected", "error", "invalid_credentials", "disconnected",
]);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function waitUntilReady(id: number, timeoutMs = 10 * 60_000): Promise<Account> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const current = await api<Account>(`/v1/accounts/${id}`);
if (TERMINAL.has(current.status)) return current;
await sleep(5_000);
}
throw new Error(`account ${id} still not ready after ${timeoutMs / 1000}s`);
}
const ready = await waitUntilReady(account.id);
if (ready.status !== "connected") {
throw new Error(`connect failed: ${ready.status}`);
}Two of the terminal states deserve different handling. error means the
terminal couldn't reach the broker or dropped the connection; that one is
worth a retry later. invalid_credentials means the broker looked at the
login and said no. Retrying won't change its mind. Fix the password with
PATCH /v1/accounts/{id} and try again.
Polling is the fallback, not the plan
Polling every five seconds is fine in a setup script. In a long-running
service it's wasted requests and a five-second blind spot. Register a webhook
and you get an account.connected event the moment the terminal is live, with
the full account object in the payload. The
signature verification post
has a complete Express receiver.
Step 4: read the account, positions, and deals
Once connected, the live fields are populated. Before that they're null
or 0, so gate on status, not on truthiness of balance.
const acct = await api<Account>(`/v1/accounts/${account.id}`);
console.log(`${acct.currency} ${acct.balance?.toFixed(2)} equity ${acct.equity?.toFixed(2)}`);
type Position = { ticket: number; symbol: string; type: "buy" | "sell"; volume: number; profit: number };
const positions = await api<Page<Position>>(`/v1/accounts/${account.id}/positions`);
for (const p of positions.data) {
console.log(p.ticket, p.symbol, p.type, p.volume, p.profit);
}
type Deal = { ticket: number; time: string; symbol: string; type: string; entry: "in" | "out"; profit: number };
const deals = await api<Page<Deal>>(
`/v1/accounts/${account.id}/deals?from=2026-01-01&limit=100`,
);
for (const d of deals.data) console.log(d.time, d.symbol, d.type, d.entry, d.profit);Two things to know before you build analytics on that deals list. Deals
aren't trades: a round trip is an in deal and an out deal sharing a
position id, and profit lands on the out. The
orders, deals, positions post
covers it. And deal history is cached for about 60 seconds server-side, so a
tight polling loop just reads the same rows back.
Every account resource is nested under /v1/accounts/{id}/. There's no
?account_id= form anywhere in the API.
The list endpoints share one pagination envelope: data, page, limit,
total, total_pages. limit defaults to 25 and caps at 100, so an account
with 600 deals since January is six requests, not one. Walk page from 1 to
total_pages and you're done; the Page<T> type above is all the typing you
need for it. The two exceptions are /candles and /ticks, which return a
count and a truncated flag instead of pages, because a price series isn't
something you want to fetch 100 rows at a time.
Error handling on the one error shape
Every non-2xx response is the same envelope, and the code values are a fixed
list. Branch on code; messages get reworded.
try {
await api<Page<Position>>(`/v1/accounts/${account.id}/positions`);
} catch (err) {
if (err instanceof MetaKitError) {
switch (err.code) {
case "account_not_running": // 409: terminal not connected yet, wait
case "upstream_error": // 502: same thing, usually
break;
case "insufficient_scope": // 403: readonly key tried to write
case "no_slots_available": // 402: buy a slot before connecting
case "invalid_credentials": // won't fix itself
default:
throw err;
}
}
throw err;
}upstream_error is the one you'll actually see in the wild, and nine times
out of ten it means the account isn't connected. Check status before you
retry, rather than hammering the endpoint.
The complete file
Save as connect.ts and run with npx tsx connect.ts (or compile it; there
are no dependencies beyond Node 18).
const BASE = "https://api.metakit.cloud";
type AccountStatus =
| "provisioning" | "starting" | "connected"
| "error" | "invalid_credentials" | "disconnected";
type Account = {
id: number;
status: AccountStatus;
currency: string | null;
balance: number | null;
equity: number | null;
open_trades: number;
};
type Page<T> = { data: T[]; total: number };
type Position = { ticket: number; symbol: string; type: "buy" | "sell"; volume: number; profit: number };
class MetaKitError extends Error {
constructor(public status: number, public code: string, message: string) {
super(message);
}
}
async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.METAKIT_KEY}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) {
const body = (await res.json()) as { error: { code: string; message: string } };
throw new MetaKitError(res.status, body.error.code, body.error.message);
}
return (await res.json()) as T;
}
const TERMINAL = new Set<AccountStatus>([
"connected", "error", "invalid_credentials", "disconnected",
]);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function connectAccount(opts: {
number: number; password: string; brokerId: number; server: string;
}): Promise<Account> {
const created = await api<Account>("/v1/accounts", {
method: "POST",
body: JSON.stringify({
name: `Account ${opts.number}`,
number: opts.number,
password: opts.password,
broker_id: opts.brokerId,
server: opts.server,
type: "full",
}),
});
const deadline = Date.now() + 10 * 60_000;
while (Date.now() < deadline) {
const current = await api<Account>(`/v1/accounts/${created.id}`);
if (TERMINAL.has(current.status)) {
if (current.status !== "connected") {
throw new Error(`connect failed: ${current.status}`);
}
return current;
}
await sleep(5_000);
}
throw new Error("account did not become ready in time");
}
async function main() {
const acct = await connectAccount({
number: 40317,
password: process.env.MT5_PASSWORD!,
brokerId: 210,
server: "ICMarketsSC-Demo",
});
console.log(`Connected: ${acct.currency} ${acct.balance?.toFixed(2)}`);
const positions = await api<Page<Position>>(`/v1/accounts/${acct.id}/positions`);
console.log(`${positions.total} open position(s)`);
for (const p of positions.data) {
console.log(` #${p.ticket} ${p.symbol} ${p.type} ${p.volume} → ${p.profit}`);
}
}
main().catch((err) => {
if (err instanceof MetaKitError) {
console.error(`${err.status} ${err.code}: ${err.message}`);
} else {
console.error(err);
}
process.exit(1);
});That runs unchanged on a Mac, in a Lambda, in a Docker container, or in a Next.js route handler. No Windows anywhere in the chain.
The rest of the MT5 API from Node.js
Reads are most of the surface: account, positions, pending orders, deals,
candles, ticks, symbol specs, and the computed /performance analytics. The
write side is POST /v1/accounts/{id}/orders plus endpoints to move stops,
close and cancel. Those need a full key and an account on a full slot,
validate volume and stops against the symbol spec before sending, and take an
Idempotency-Key so a retried fetch cannot fill twice. If you'd rather
not write execution code at all, trade a source account and a
copier replicates it onto followers.
The full reference is a single docs page, or llms.txt if you'd rather hand it to a coding agent and let it write the types for you.