Quickstart
From sign-up to a filled demo trade, closed again, in one page
Nothing here touches real money: a test key cannot reach a live account
The curl blocks below run in order against a demo account; test/quickstart.mjs in the site repository extracts them from this page and runs them against the mock gateway on every change, so they cannot drift from what the gateway does
The Node and Python blocks are the same calls in a program
From step 8 on, the same integration with the typed JavaScript client (download v0.7.1): its read and trade examples are run against the same mock by the same test, and its account-state stream
1Sign up
Sign up in the console with an email address and a password
The console opens at once; confirming the address is a nudge, not a door
2Create a test key
In the console, API access → Generate API access token
Give it the read, trade and provision scopes and the test environment
The secret is shown once; keep it as MIRROAPI_KEY
Test keys and demo accounts A key is mk_test_… or mk_live_…
A test key trades and records consent on demo accounts only: on a live account it is refused with 403 TEST_KEY_LIVE_ACCOUNT, and it can only create other test keys (403 TEST_KEY_LIVE_KEY)
Everything else — reads, the audit log, usage — is the same, so the code you write here runs unchanged with a live key
A demo account is a broker demo (practice money) on a hosted MetaTrader terminal; it costs the same as any other account: 6 USD per 30 days on a plan, or by the hour without one
export BASE=https://api.mirroapi.cloud/v1
export MIRROAPI_KEY=mk_test_... # the secret the console showed once
3Add a demo MetaTrader account
The login, password and server of a broker demo account, MetaTrader 5 or 4 (platform: mt4 works the same)
MirroAPI starts a terminal for it; state goes DEPLOYING → DEPLOYED and connection becomes CONNECTED, usually within a minute
GET /platforms says which platforms can be added this way today (selfOnboard.ok) and why not when they cannot
curl -s -X POST $BASE/accounts \
-H "Authorization: Bearer $MIRROAPI_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"platform":"mt5","name":"Quickstart demo","login":"51234567","password":"<DEMO_PASSWORD>","server":"ICMarketsSC-Demo","environment":"demo","role":"follow"}'
# → 201 { "id": "<ACCOUNT_ID>", "state": "DEPLOYING", "connection": "DISCONNECTED", ... }
# Poll until the terminal is up and signed in to the broker
curl -s $BASE/accounts/<ACCOUNT_ID> -H "Authorization: Bearer $MIRROAPI_KEY"
# → { "state": "DEPLOYED", "connection": "CONNECTED", ... }
// Node.js 18+ (built-in fetch). Run: MIRROAPI_KEY=... node quickstart.mjs
import { randomUUID } from 'node:crypto';
const BASE = 'https://api.mirroapi.cloud/v1';
const api = async (method, path, body, idem) => {
const res = await fetch(BASE + path, { method, headers: { authorization: `Bearer ${process.env.MIRROAPI_KEY}`, 'content-type': 'application/json', ...(idem ? { 'idempotency-key': idem } : {}) }, body: body ? JSON.stringify(body) : undefined });
const json = res.status === 204 ? null : await res.json();
if (!res.ok) throw Object.assign(new Error(json?.error?.message ?? res.statusText), { code: json?.error?.code, details: json?.error?.details, docs: json?.error?.docs });
return json;
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let account = await api('POST', '/accounts', { platform: 'mt5', name: 'Quickstart demo', login: '51234567', password: process.env.DEMO_PASSWORD, server: 'ICMarketsSC-Demo', environment: 'demo', role: 'follow' }, randomUUID());
while (account.state !== 'DEPLOYED' || account.connection !== 'CONNECTED') { await sleep(2000); account = await api('GET', `/accounts/${account.id}`); }
const ACCOUNT = account.id;
# Python 3.9+ pip install requests Run: MIRROAPI_KEY=... python quickstart.py
import os, time, uuid, requests
BASE = "https://api.mirroapi.cloud/v1"
S = requests.Session()
S.headers["Authorization"] = "Bearer " + os.environ["MIRROAPI_KEY"]
def api(method, path, body=None, idem=None):
r = S.request(method, BASE + path, json=body, headers={"Idempotency-Key": idem} if idem else None)
data = None if r.status_code == 204 else r.json()
if not r.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']} ({data['error']['docs']})")
return data
account = api("POST", "/accounts", {"platform": "mt5", "name": "Quickstart demo", "login": "51234567", "password": os.environ["DEMO_PASSWORD"], "server": "ICMarketsSC-Demo", "environment": "demo", "role": "follow"}, str(uuid.uuid4()))
while account["state"] != "DEPLOYED" or account["connection"] != "CONNECTED":
time.sleep(2)
account = api("GET", f"/accounts/{account['id']}")
ACCOUNT = account["id"]
4Send a market order
Trades are asynchronous
POST /trades answers 202 with a Command: the order is on the ledger and handed to the terminal; the fill comes back on the command, not in this answer
The Idempotency-Key is what makes a retry safe: the same key never places twice, and a replay answers the command as it is now with Idempotent-Replayed: true
Volume is a decimal string, never a float
curl -s -X POST $BASE/accounts/<ACCOUNT_ID>/trades \
-H "Authorization: Bearer $MIRROAPI_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"action":"market","symbol":"EURUSD","side":"buy","volume":"0.01"}'
# → 202 { "id": "<COMMAND_ID>", "action": "market", "state": "claimed", "request": { ... }, "result": {}, "expiresAt": "..." }
let cmd = await api('POST', `/accounts/${ACCOUNT}/trades`, { action: 'market', symbol: 'EURUSD', side: 'buy', volume: '0.01' }, randomUUID());
cmd = api("POST", f"/accounts/{ACCOUNT}/trades", {"action": "market", "symbol": "EURUSD", "side": "buy", "volume": "0.01"}, str(uuid.uuid4()))
A refusal is a 422 with a stable code, the numbers in details, and docs pointing at the error catalogue
Branch on the code, never on the message
Nothing was queued when you get a 422
5Poll the command until it resolves
GET /trades/{commandId} until state leaves queued / claimed / sent
The endings:
| state | result.code | Meaning |
|---|---|---|
filled | — | result.ticket, result.price, result.filledLots, result.latencyMs (gateway receipt to broker acknowledgement) |
rejected | BROKER_REJECTED | The terminal's final refusal; result.broker is its wordA transient answer (requote, off quotes) is re-driven for you until the command expires |
rejected | EXECUTION_REFUSED | MIRRO's own engine refused it before any terminal saw it; result.reason is the word and there is no result.broker |
refused | RISK_*, NO_CONSENT, … | The gateway's refuse-to-send gate, also answered as the 422 above |
expired | EXECUTION_NOT_CLAIMED | Nothing claimed it before it expired Nothing was sent; send again |
expired | EXECUTION_UNKNOWN | Read, never resend A terminal took the command and no answer came back The outcome is not known and it is never called rejected: read GET /positions and the deal history before sending anything againA late answer can still correct the row ( result.correctedFrom) |
curl -s $BASE/accounts/<ACCOUNT_ID>/trades/<COMMAND_ID> -H "Authorization: Bearer $MIRROAPI_KEY"
# → { "state": "filled", "result": { "ticket": "<TICKET>", "price": "1.16912", "filledLots": "0.01", "latencyMs": 6 } }
while (['queued', 'claimed', 'sent'].includes(cmd.state)) { await sleep(250); cmd = await api('GET', `/accounts/${ACCOUNT}/trades/${cmd.id}`); }
console.log(cmd.state, cmd.result);
if (cmd.state === 'expired' && cmd.result.code === 'EXECUTION_UNKNOWN') throw new Error('outcome not known: read the positions before sending again');
while cmd["state"] in ("queued", "claimed", "sent"):
time.sleep(0.25)
cmd = api("GET", f"/accounts/{ACCOUNT}/trades/{cmd['id']}")
print(cmd["state"], cmd["result"])
if cmd["state"] == "expired" and cmd["result"].get("code") == "EXECUTION_UNKNOWN":
raise SystemExit("outcome not known: read the positions before sending again")
6Read the book
GET /positions is the account's open positions as its terminal last reported them
An empty list means the account is flat
A 503 BOOK_UNAVAILABLE is not an empty book: the book could not be read and nothing about it is known from that answer — retry, and never act as if the account were flat
MT4: positionId versus ticket On MetaTrader 4 a partial close ends the ticket and gives the remainder a new one
ticket is what a close must name (the live order ticket now); positionId is the stable identity of the position (the origin ticket on MT4, the position id on MT5)
Key your own records on positionId; name ticket when you act
A close-partial that names a superseded ticket is refused 422 TICKET_SUPERSEDED with the live ticket in details.liveTicket, so a retry never cuts the remainder twice
curl -s $BASE/accounts/<ACCOUNT_ID>/positions -H "Authorization: Bearer $MIRROAPI_KEY"
# → [ { "ticket": "<TICKET>", "positionId": "<TICKET>", "symbol": "EURUSD", "side": "buy", "volume": "0.01", "openPrice": "1.16912", "timeBasis": "utc", ... } ]
for (const p of await api('GET', `/accounts/${ACCOUNT}/positions`)) console.log(p.ticket, p.positionId, p.symbol, p.side, p.volume, p.profit);
for p in api("GET", f"/accounts/{ACCOUNT}/positions"):
print(p["ticket"], p["positionId"], p["symbol"], p["side"], p["volume"], p.get("profit"))
7Close it
A close names the ticket
Exits are never refused by risk rules
Poll the command the same way; then the book is empty again
curl -s -X POST $BASE/accounts/<ACCOUNT_ID>/trades \
-H "Authorization: Bearer $MIRROAPI_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"action":"close","ticket":"<TICKET>"}'
curl -s $BASE/accounts/<ACCOUNT_ID>/trades/<CLOSE_ID> -H "Authorization: Bearer $MIRROAPI_KEY"
# → { "state": "closed", "result": { "ticket": "<TICKET>", "filledLots": "0.01", "price": "...", "latencyMs": 5 } }
curl -s $BASE/accounts/<ACCOUNT_ID>/positions -H "Authorization: Bearer $MIRROAPI_KEY"
# → []
let close = await api('POST', `/accounts/${ACCOUNT}/trades`, { action: 'close', ticket: cmd.result.ticket }, randomUUID());
while (['queued', 'claimed', 'sent'].includes(close.state)) { await sleep(250); close = await api('GET', `/accounts/${ACCOUNT}/trades/${close.id}`); }
console.log(close.state, (await api('GET', `/accounts/${ACCOUNT}/positions`)).length, 'open positions');
close = api("POST", f"/accounts/{ACCOUNT}/trades", {"action": "close", "ticket": cmd["result"]["ticket"]}, str(uuid.uuid4()))
while close["state"] in ("queued", "claimed", "sent"):
time.sleep(0.25)
close = api("GET", f"/accounts/{ACCOUNT}/trades/{close['id']}")
print(close["state"], len(api("GET", f"/accounts/{ACCOUNT}/positions")), "open positions")
8Use the typed JavaScript client
The same calls, with a clear recovery path: read your account, submit one identifiable operation, and follow its actual outcome
The same REST trade actions serve MT4 and MT5
Download client v0.7.1
Node.js 20 or later
No runtime dependencies
Install this versioned client directly from the download URL below
npm install https://mirroapi.com/downloads/mirroapi-client-0.7.1.tgz
Connect an account in the console (or use the demo account from step 3)
Create an API key limited to that account and the scopes your application needs (API keys)
Keep the key in your server’s secret store; never embed it in a public browser bundle
The client examples read MIRRO_API_KEY and MIRRO_ACCOUNT_ID: the same key as MIRROAPI_KEY above, and the account's id
export MIRRO_API_KEY='your-key-from-the-console'
export MIRRO_ACCOUNT_ID='your-account-UUID'
curl --fail-with-body \
-H "Authorization: Bearer $MIRRO_API_KEY" \
https://api.mirroapi.cloud/v1/capabilities
The capabilities response describes the running release, including unavailable features and this key’s limits
Account permissions, broker connectivity and EA compatibility still apply
import {MirroClient} from '@mirroapi/client';
const api = new MirroClient({apiKey: process.env.MIRRO_API_KEY});
try {
const accountId = process.env.MIRRO_ACCOUNT_ID;
const account = await api.account(accountId);
const symbols = await api.symbols(accountId);
const positions = await api.positions(accountId);
console.log({account, symbols, positions});
} finally {
api.close();
}
Preserve the exact symbol returned by the account, such as BTCUSDm
Volume and price inputs are decimal strings
A read failure is an error, never a fabricated empty account
Try the complete demo flow
The runnable demo script includes durable local receipts and restart recovery
Use a dedicated, empty, broker-confirmed demo account with copying disabled and an account-limited read/trade key
Node.js 20+ on Linux or macOS is required for its durable filesystem writes
It refuses live accounts
curl --fail --proto '=https' --tlsv1.2 \
https://mirroapi.com/downloads/mirro-demo-1.mjs -o mirro-demo.mjs
node mirro-demo.mjs inspect
# Use the exact symbol returned above; this example is broker-specific.
node mirro-demo.mjs inspect --symbol BTCUSDm
node mirro-demo.mjs open --symbol BTCUSDm --volume 0.01
node mirro-demo.mjs resume
node mirro-demo.mjs close
Install client v0.7.1 first, as shown above
The script checks the account’s symbol, volume rules and fresh quote
Each receipt is saved before sending
resume recovers the same operation after a lost reply; once the command ID is known it only queries that command
Concurrent starts in the same directory share one identity
After preparing a close, resume follows that close
Keep mirro-demo-receipts private and intact, with the original key and gateway
A known command can still be queried later; an unknown operation older than 23 hours requires manual reconciliation
Never delete receipts to retry an uncertain order
A new receipt directory means a deliberate new trading cycle
This small tutorial caps volume at 0.01 lots; it is not a portfolio manager or a promise of broker acceptance
9Give each trade a durable identity
Save the prepared operation in your application’s durable storage before submitting
In this example, saveOperation is your own storage function, and api and accountId are the client and the account from step 8
Review the account, symbol and amount before enabling execution
const operation = api.prepareTrade(accountId, {
action: 'market', symbol: 'BTCUSDm', side: 'buy', volume: '0.01'
});
await saveOperation(operation);
const accepted = await api.submitTrade(operation);
const result = await api.waitForCommand(accountId, accepted.id);
console.log(result.state, result.result);
A 202 response means accepted
Only the command’s final state confirms a fill, close or rejection
To close a position, prepare a new operation with {action:'close', ticket:'the-ticket'}
After a lost response, reload and submit the same saved operation
Never create a fresh identity merely because a request timed out
Server idempotency retention is 24 hours; beyond that window, reconcile the original command/account before resubmitting
A timeout while waiting carries the command ID so you can resume checking it
EXECUTION_UNKNOWN requires reconciliation; it does not mean nothing happened
10Keep load bounded
- The client defaults to six active reads and two trade submissions, with 64 queued operations per lane and up to 32 command waiters
- GETs and durable trade submissions use bounded retries, jitter and
Retry-After
Other writes are not automatically retried CLIENT_BUSYmeans the client queue is full
503 API_BUSYmeans gateway concurrency is full
Slow the producer and reuse any existing trade identity429 RATE_LIMITEDcarries a retry delay
Read and trade quotas are separate
X-RateLimit-Resetis Unix time in seconds- Cancellation stops client work; it does not undo an accepted broker order
Use onResponse(meta) for request IDs, response status, retry attempt and gateway timing
No credentials or request bodies are included
Gateway timing, MIRRO routing and broker-confirmed completion measure different intervals
11Subscribe to account state
Connect a standard WebSocket client to wss://api.mirroapi.cloud/v1/stream
Supply a bearer header, or wait for auth-required and send the first message below
Never put a key in the URL
Client v0.7.1 manages reconnects, replay, resets and bounded buffering
Use Node.js 22+ for the built-in WebSocket, or pass a compatible constructor on Node.js 20
const stream = api.stream({
subscriptions: [{accountId, channels: ['positions', 'orders']}]
});
try {
for await (const event of stream) {
if (event.type === 'snapshot') {
await saveSnapshot(event); // your storage: replace the whole channel
} else if (event.type === 'status') {
setStreamState(event.state); // your UI or monitoring
} else {
markCacheUnknown(event); // a reset/error is not an empty account
}
}
} finally {
stream.close();
}
The three application functions above are yours to implement
The client resumes from delivered snapshots and labels replayed data
synchronized means every subscribed channel has a fresh stored-state snapshot in this recovery cycle; check terminalFresh and brokerConnected separately
A slow consumer receives CLIENT_STREAM_BACKPRESSURE; revoked access terminates recovery
Defaults are 32 queued events, 1 MiB of buffered data and eight failed reconnect attempts
Breaking the loop, aborting the signal or closing the client releases the stream
The gateway allows 30 WebSocket upgrade attempts per minute per IP, with four simultaneous sockets per IP and two per key
Repeated connection failures can exhaust the client’s recovery budget
Catch the stream error, mark state unknown, and delay a new stream instead of reconnecting in a tight loop
For integrations using their own WebSocket client, the wire protocol is:
{"op":"auth","token":"your-api-key"}
// After ready:
{"op":"subscribe","accountId":"your-account-UUID",
"channels":["account","positions","orders","commands"]}
Snapshots carry sessionId, seq, ch, source, terminalFresh and full data
They reflect stored terminal state, polled by default every second; unchanged snapshots are suppressed
They do not contain every market tick
Snapshot reads consume the key’s read quota
// Reconnect, authenticate, wait for ready, then resume before subscribing:
{"op":"resume","sessionId":"previous-session-UUID","after":1042}
On RESUME_UNAVAILABLE, discard cached state and subscribe again
On REPLAY_GAP, discard cached state and accept fresh snapshots for retained subscriptions
Replay is process-local and bounded to at most five minutes, 256 frames and 1 MiB; restart or host changes can require a reset
Handle STREAM_BUSY by retrying the control message after a short delay
Use bounded reconnect backoff and jitter
A ready frame with resumeRefresh: true guarantees fresh snapshots after replay; sequences above the resumed.seq value are newly read snapshots
Connect TradingView without writing code
Open TradingView signals, choose New connection, select your demo account and review the trade size
The setup page writes your messages for you
First send its harmless test from TradingView; then choose what a trade alert should do
Copy the message into TradingView’s Message box and the URL into Notifications → Webhook URL
A connected account, a received test and a confirmed broker trade are shown separately
Developers can use the same connection through client v0.7.1
The following example uses api and the verified demo accountId from step 8
It creates a connection and previews a message; it sends no trading alert
Keep connection details in your private server storage and show them only to their owner
const connection = await api.createHook({
name: 'TradingView demo', accountIds: [accountId],
defaults: {lots: 0.01, sl: 20, tp: 40},
policy: {scope: 'own', onSame: 'skip', onOpposite: 'refuse'}
});
const setup = await api.hookTemplates(connection.id);
const message = setup.commands.find(item => item.command === 'buy').message;
// Replace this with an exact symbol from this account's catalogue.
const preview = await api.previewHook(connection.id, message.replace('{{ticker}}', 'BTCUSDm'));
console.log({ready: preview.ok, message: preview.message});
// setup.url goes in TradingView. Never log it or the message secret.
A preview uses current prices and positions; it cannot guarantee a future fill
The gateway records and queues a real alert before returning HTTP 202
Follow api.hookAlerts(connection.id) and inspect each command’s broker result
Each intended event needs a unique nonce; retries retain the same value
An unknown outcome needs reconciliation, not a new event
The console separately builds Pine alert() and strategy alert_message expressions
Existing PineConnector messages need their credentials and units reviewed before migration; complete syntax compatibility is not offered
What this release supports
Account management, positions, working orders, observed deal history, exact symbol catalogues, asynchronous trading, copy links, risk settings and signed webhooks are available through documented routes
Compatible MT4/MT5 terminals also provide guarded quote, server-time and margin reads
MT4 margin checks return free margin before/after; MT5 returns an order margin calculation
Historical-order queries, candle or tick history, depth of market, computed statistics, automatic cross-host failover and high reliability are not offered by this release
Stored-state streaming is distinct from a live tick feed
Check the reference and /capabilities before integrating
12Where to next
- The API reference: every route the gateway serves, as it serves it
Paths that are planned but not served are hidden by default - The error catalogue: every code, its status and what to do
Thedocsfield on every error links straight to its row PUT /accounts/{id}/risk: max lots per order, open lots, open positions, daily loss and trading hours, enforced before an order leaves
A rule the gateway cannot enforce yet is refused (RISK_RULE_UNSUPPORTED), not storedPOST /webhookswithcommand.resolved: every ending of every command, signed, retried for about 28 hours- The client's own README is inside the download;
GET /capabilitiessays what the running release serves and this key's limits - Going live: a live key, a live account, and the trader's recorded consent (
POST /accounts/{id}/consent) before the first order
API v1 · Client v0.7.1 · Contract reviewed 19 September 2026
Availability and broker execution depend on the deployed infrastructure and broker; these examples are not an uptime or latency guarantee