MT5 API from Node.js: connect an account in TypeScript

10 min readMetaKit

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 fetch is global and there's no axios to 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 full account that can be a copier follower.
  • A MetaKit API key from the dashboard under Settings, API keys. A readonly key can't create accounts, so you'll need a full key 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 provisioning

The 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.