REST API v1
The public API for the mt4-quote-bridge CLI and your own scripts: exact market, account, terminal and trading-request
resources. It answers on the app's HTTP port (shown in Settings), on this computer only (127.0.0.1, localhost or [::1]), under /api/v1. A tunnel must send one of those as the
Host header (for ngrok, --host-header=rewrite); see AI agents and remote access.
This is a clean-slate contract. It replaced the earlier legacy-compatible read API (/symbols…, /history…,
/indicators/:name/:symbol) with no adapters: those paths now answer 404 unknown-route. The data behind it is
unchanged: candles, history and exports come from the same chart history and History-page pipeline as the app
window (see Chart history), and indicator values from the same calculation cache as the charts.
Every example on this page is executable: the test suite replays each request and response below against a
running server. <any> stands for any value; <$name> for a value kept as {name} in later examples.
Authentication and scopes
- Create a key in the app window › Settings › API keys with scope
readonlyortrader. The app shows the key once. Send it asAuthorization: Bearer qbk_…. readonlyreads everything below but screenshots.traderreads too, takes screenshots, and may preview, execute and review trading requests. Areadonlykey on anything butGETorHEAD, or on a screenshot, answers403 insufficient-scope.- A missing, unknown or revoked key answers
401 unauthenticated. The key and its current scope are checked on every request, including a repeated execute. - No key administers the app. Installation and setup, API keys, network settings, recording and trading enablement, lot cap and slippage are the app window's alone.
- Previews have no owner: any
traderkey, or the app window, may execute a valid preview. GET /api/v1/infois public.GET /api/auth/session(outside v1) tells the CLI whose key it holds:{ username, key: { name, prefix, scope }, expires: null }.
GET /api/v1/info
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "data": { "service": "mt4-quote-bridge", "apiVersion": 1 }, "meta": {} }
Conventions
Envelopes. Success answers { "data": …, "meta": { … } }; a collection's data is an array. Errors answer
{ "error": { "code", "message", "details" } }: code is stable (see Error codes), message
is for people and may change, details is null or an object such as the offending parameter. Exports are
files, not envelopes; their errors are still structured.
Identities. Markets, accounts, terminals, feeds, indicator packages and requests are named by exact,
opaque, stable string IDs; tickets are strings. Nothing is matched by case, substring, readable label or
default: a market is one broker server and one symbol exactly (EURUSD and eurusd are two markets), an account
is one broker server and one login, however many terminals share it. IDs are references, not secrets. Discovery
answers the readable fields (brokerServer, symbol, login, label) beside them.
Numbers. Prices, lots and money are decimal strings ("1.10015", "0.1", "-1.25"), never JSON numbers.
Quote and candle prices have the market's digits; account values are as the terminal reports them, without
trailing zeros, with the account's currency. Counts, digits, points and leverage are integers. Unavailable values
are null, never zero. String encoding does not make a value more precise than its source: candles and indicator
values are the app's calculated doubles written out exactly.
Times. Every time is UTC ISO-8601 ("2027-01-15T08:00:00.000Z"). Where MetaTrader stamps broker time,
a sibling brokerTime (or …BrokerTime) gives the broker server's wall clock without an offset
("2027-01-15T10:00:00"). UTC comes from the broker server's clock rule (Terminals view); while a rule is unknown,
broker time is taken as UTC. Time parameters take UTC as YYYY-MM-DDTHH:MM:SS[.sss]Z only.
Parameters. A resource refuses parameters it does not take (400 unknown-parameter), so there is no symbol
search, broker substring, terminal or feed override and no data-source selector on ordinary reads.
Paging. Discovery and journal collections are cursor-paged: limit (100 by default, 500 at most) and the
cursor from meta.nextCursor, null on the last page. Their order is stable and nothing is skipped or repeated
while items are added. Samples are cursor-paged too (1,000 by default, 10,000 at most), stable even where samples
share a time. Candles and indicator values are time-paged: the newest limit bars within from/to, oldest first;
meta.next.to is the to of the page before, null once from or the oldest bar is reached.
Status codes. 400 malformed request, 401 unauthenticated, 403 forbidden (scope, demo or real-account trading off),
404 unknown resource or route, 405 wrong method, 409 ambiguity, conflict, an expired or invalidated preview, or a busy app window,
413/415 body too large or not JSON, 422 invalid trading or indicator values, 503 source or app window unavailable,
504 the terminal did not answer within five seconds, or a screenshot took more than ten.
Markets
| Endpoint | Answer |
|---|---|
GET /markets |
Every known market: with a feed now, recorded, saved or in a terminal's HST folder; by symbol, then broker server. Exact filters brokerServer and symbol; cursor-paged. |
GET /markets/{marketId} |
One market: { id, brokerServer, symbol, label, digits, connected, recording, sources: [{ terminalId, feedId, connected }] }. |
GET /markets/{marketId}/quote |
The best source's quote with provenance and freshness. |
GET /markets/{marketId}/candles |
timeframe (required), from, to, limit (900; 1–5000). |
GET /markets/{marketId}/samples |
Recorded samples: from, to, limit (1000; 1–10,000), cursor. |
GET /markets/{marketId}/history |
Closed-bar coverage per timeframe. |
GET /markets/{marketId}/export |
A file: timeframe and format (csv or hst) required, from, to. |
GET /markets/{marketId}/indicators/{packageId} |
An indicator's values: as candles, plus p.<parameter>. |
timeframe is one of M1 M5 M15 M30 H1 H4 D1 W1 MN1, exactly.
GET /api/v1/markets?brokerServer=EasyForex-Demo
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": [
{
"id": "{market}",
"brokerServer": "EasyForex-Demo",
"symbol": "EURUSD",
"label": "EURUSD · EasyForex-Demo",
"digits": 5,
"connected": true,
"recording": true,
"sources": [{ "terminalId": "mt4-aaaaaaaaaaaa", "feedId": "[\"mt4-aaaaaaaaaaaa\",\"EURUSD\"]", "connected": true }]
}
],
"meta": { "count": 1, "limit": 100, "nextCursor": null }
}
Quote
Within one exact market, the source is the connected one with the newest broker quote time, then the newest
receipt, then the lowest terminal ID; it fails over automatically. Offline, the last-known quote answers with
state: "last-known"; with no feed, the last recorded sample with state: "recorded" and null terminal
provenance; with neither, 503 quote-unavailable. quoteAgeMs is the broker quote's age; sourceSilenceMs is the
time since the source's last samples (silent at five seconds). A weekend's old quote is not a disconnection.
stale is true when offline, silent or when the quote time has not moved for ten seconds. Feeds of one terminal are
under Terminals; this resource is not execution routing.
GET /api/v1/markets/{market}/quote
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": {
"marketId": "{market}",
"brokerServer": "EasyForex-Demo",
"symbol": "EURUSD",
"digits": 5,
"bid": "1.10000",
"ask": "1.10015",
"spread": "15.0",
"time": "2027-01-15T08:00:00.000Z",
"brokerTime": "2027-01-15T10:00:00",
"state": "connected",
"stale": false,
"quoteAgeMs": 0,
"sourceSilenceMs": 0,
"sourceSilent": false,
"provenance": { "terminalId": "mt4-aaaaaaaaaaaa", "feedId": "[\"mt4-aaaaaaaaaaaa\",\"EURUSD\"]", "receivedAt": "2027-01-15T08:00:00.000Z" }
},
"meta": {}
}
spread is in points.
Candles
The chart's bars, as the Charts view shows them, oldest first: the newest limit whose start is from from to
to. The newest live candle is still forming: complete: false. meta.source is the feed whose chart history
answered, null for a market known from HST files and saved history alone.
GET /api/v1/markets/{market}/candles?timeframe=H1&limit=2
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": [
{ "time": "2027-01-15T07:00:00.000Z", "brokerTime": "2027-01-15T09:00:00", "open": "1.10020", "high": "1.10070", "low": "1.10000", "close": "1.10050", "complete": true },
{ "time": "2027-01-15T08:00:00.000Z", "brokerTime": "2027-01-15T10:00:00", "open": "1.10000", "high": "1.10000", "low": "1.10000", "close": "1.10000", "complete": false }
],
"meta": {
"marketId": "{market}",
"timeframe": "H1",
"digits": 5,
"source": { "terminalId": "mt4-aaaaaaaaaaaa", "feedId": "[\"mt4-aaaaaaaaaaaa\",\"EURUSD\"]" },
"count": 2,
"limit": 2,
"next": { "to": "2027-01-15T06:59:59.000Z" }
}
}
To page back, ask again with to set to meta.next.to.
GET /api/v1/markets/{market}/candles?timeframe=1H
Authorization: Bearer <readonly key>
HTTP/1.1 400 Bad Request
{
"error": {
"code": "invalid-timeframe",
"message": "timeframe must be one of M1, M5, M15, M30, H1, H4, D1, W1, MN1.",
"details": { "parameter": "timeframe", "allowed": ["M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1", "MN1"] }
}
}
Samples
A recorded market's samples (switch recording on in the app's Feeds view), oldest first; samples of one second are
in one stable order. from and to are inclusive. The cursor resumes exactly after the last sample given; send it
with the same from and to (another market or other bounds answer 400 invalid-cursor).
GET /api/v1/markets/{market}/samples?limit=10
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": [
{ "time": "2027-01-15T08:00:00.000Z", "brokerTime": "2027-01-15T10:00:00", "bid": "1.10000", "ask": "1.10015", "receivedAt": "2027-01-15T08:00:00.000Z" }
],
"meta": { "marketId": "{market}", "digits": 5, "count": 1, "limit": 10, "nextCursor": null }
}
History and export
history lists each timeframe with closed bars: MetaTrader's HST file joined with the app's saved history, as the
History page shows it. It answers with no terminal running once history is saved.
GET /api/v1/markets/{market}/history
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": {
"marketId": "{market}",
"digits": 5,
"timeframes": [
{ "timeframe": "H1", "bars": 3, "from": "2027-01-15T05:00:00.000Z", "fromBrokerTime": "2027-01-15T07:00:00", "to": "2027-01-15T07:00:00.000Z", "toBrokerTime": "2027-01-15T09:00:00" }
]
},
"meta": {}
}
export downloads the same file the History page does, through the same pipeline, never the forming candle:
csv with broker time and a UTC column, or hst (MetaTrader's v401 format, broker time). from and to keep only
the bars that start between them. The file is streamed whole; there is no row limit.
GET /api/v1/markets/{market}/export?timeframe=H1&format=csv
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename*=UTF-8''EURUSD60.csv
time,open,high,low,close,tick_volume,spread,real_volume,time_utc
2027-01-15 07:00:00,1.10000,1.10050,1.09980,1.10030,10,0,0,2027-01-15T05:00:00Z
2027-01-15 08:00:00,1.10010,1.10060,1.09990,1.10040,11,0,0,2027-01-15T06:00:00Z
2027-01-15 09:00:00,1.10020,1.10070,1.10000,1.10050,12,0,0,2027-01-15T07:00:00Z
Indicator values
The values of an enabled package (see Indicators) over exactly the bars candles answers for the same
timeframe, from, to and limit, read from the cache the charts use: the CLI gets the very values a chart
shows. Parameters are p.<id>; any left out take their defaults, and a value out of range answers
400 invalid-indicator-parameter.
- The package also runs over the warm-up bars it declares before the first bar, so the first value is as correct
as the last.
meta.barsUsedis how many bars it ran over. - A closed-bars-only package has no row for the forming bar.
- A value is the calculated double written out exactly, the shortest decimal that reads back as the same number
(
"1.1004500000000002"): round it to the market's digits for display. - A value is
nullwhere the package has none (during its warm-up, for instance).stylesappears for outputs coloured by style, such as rising and falling. - Values need a terminal's chart of the market (
503 feed-requiredwithout one). A calculation that throws, runs too long or returns bad values answers422 indicator-failedwith the reason indetails.
GET /api/v1/markets/{market}/indicators/quotebridge.ma?timeframe=H1&limit=2&p.period=2
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": [
{ "time": "2027-01-15T07:00:00.000Z", "brokerTime": "2027-01-15T09:00:00", "complete": true, "values": { "ma": "1.1004500000000002" } },
{ "time": "2027-01-15T08:00:00.000Z", "brokerTime": "2027-01-15T10:00:00", "complete": false, "values": { "ma": "1.1002500000000002" } }
],
"meta": {
"marketId": "{market}",
"indicatorId": "quotebridge.ma",
"version": "1.0.0",
"parameters": { "period": 2, "type": "simple" },
"outputs": [{ "id": "ma", "label": "MA" }],
"timeframe": "H1",
"source": { "terminalId": "mt4-aaaaaaaaaaaa", "feedId": "[\"mt4-aaaaaaaaaaaa\",\"EURUSD\"]" },
"count": 2,
"limit": 2,
"barsUsed": 4,
"next": { "to": "2027-01-15T06:59:59.000Z" }
}
}
Accounts
| Endpoint | Answer |
|---|---|
GET /accounts |
Known trading accounts, by broker server and login; cursor-paged. |
GET /accounts/{accountId} |
One account's identity, connection and sources. |
GET /accounts/{accountId}/funds |
Balance, equity, margin, free margin, margin level, profit, currency, leverage and freshness. |
GET /accounts/{accountId}/positions[/{ticket}] |
Open positions only; cursor-paged by ticket. |
GET /accounts/{accountId}/orders[/{ticket}] |
Pending limit and stop orders only; cursor-paged by ticket. |
GET /accounts/{accountId}/markets/{marketId}/conditions |
The market's contract data from the account's terminal. |
An account is one broker server and login; another terminal on it adds a source, not an account. There is no
active or default account. Accounts the app has seen stay listed after a restart, offline: connected: false,
state: "unavailable" and null Funds until a terminal reconnects. Funds, positions and orders are read fresh from one
connected terminal of the account (never balance from one terminal with positions from another), within five
seconds (504 read-timeout); offline they answer the last complete snapshot with state: "stale", or nulls.
Financial snapshots are kept in memory only.
executor says whether the account can trade and from which terminal: ready or not-ready for its trading
chart, else zero, multiple or real-account. Any connected chart of a demo account can trade (an EA older than
1.13 only with its old EnableDemoTrading input on; none is zero), and the first ready one is used. A real account
trades only from exactly one chart with EnableRealTrading (EA 1.13 or later): none is real-account, more than
one multiple. executorEnabled on a source says whether that chart may be the trading chart. A ready real account still trades only while Real-account
trading is on in the app window's Settings. demo is what MetaTrader reports (IsDemo()), or null until a
terminal has said.
GET /api/v1/accounts
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": [
{
"id": "{account}",
"brokerServer": "EasyForex-Demo",
"login": "876543219",
"label": "EasyForex-Demo · 876543219",
"demo": true,
"connected": true,
"state": "unavailable",
"currency": "USD",
"executor": { "state": "ready", "terminalId": "mt4-aaaaaaaaaaaa" },
"sources": [{ "terminalId": "mt4-aaaaaaaaaaaa", "connected": true, "executorEnabled": true, "ready": true, "maxLots": "0.5" }]
}
],
"meta": { "count": 1, "limit": 100, "nextCursor": null }
}
GET /api/v1/accounts/{account}/funds
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": {
"accountId": "{account}",
"currency": "USD",
"balance": "1000",
"equity": "999",
"margin": "10",
"freeMargin": "989",
"marginLevel": "9990",
"profit": "-1",
"leverage": 100,
"state": "fresh",
"capturedAt": "2027-01-15T08:00:00.000Z",
"capturedBrokerTime": "2027-01-15T10:00:00",
"receivedAt": "2027-01-15T08:00:00.000Z",
"terminalId": "mt4-aaaaaaaaaaaa"
},
"meta": {}
}
marginLevel is a percentage, null without margin in use.
Positions and pending orders
Tickets are MetaTrader's and are unique only within an account: always address one under its account. Trades
placed outside QuoteBridge are listed too. MetaTrader's zero stop loss, take profit or expiry is null.
GET /api/v1/accounts/{account}/positions
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": [
{
"ticket": "12345",
"accountId": "{account}",
"marketId": "{market}",
"symbol": "EURUSD",
"side": "buy",
"lots": "0.1",
"stopLoss": null,
"takeProfit": null,
"magic": -42,
"openPrice": "1.1",
"openTime": "2023-11-14T20:13:20.000Z",
"openBrokerTime": "2023-11-14T22:13:20",
"currentPrice": "1.09",
"profit": "-1.25",
"swap": "0",
"commission": "0"
}
],
"meta": { "accountId": "{account}", "currency": "USD", "state": "fresh", "count": 1, "limit": 100, "nextCursor": null }
}
GET /api/v1/accounts/{account}/orders/12346
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": {
"ticket": "12346",
"accountId": "{account}",
"marketId": "{market}",
"symbol": "EURUSD",
"side": "buy",
"type": "limit",
"lots": "0.1",
"stopLoss": null,
"takeProfit": null,
"magic": -42,
"price": "1.1",
"placedAt": "2023-11-14T20:13:20.000Z",
"placedBrokerTime": "2023-11-14T22:13:20",
"expiry": null,
"expiryBrokerTime": null,
"currentPrice": "1.09"
},
"meta": { "currency": "USD", "state": "fresh" }
}
Conditions
A market's contract data, read from the account's terminal: its trading chart when there is one, else another connected chart of the account. Reading needs no trading permission and works on real accounts. The market must be on the account's broker server.
GET /api/v1/accounts/{account}/markets/{market}/conditions
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": {
"accountId": "{account}",
"marketId": "{market}",
"symbol": "EURUSD",
"bid": "1.1",
"ask": "1.10002",
"digits": 5,
"point": "0.00001",
"tickSize": "0.00001",
"minLots": "0.01",
"maxLots": "100",
"lotStep": "0.01",
"stopLevel": 10,
"freezeLevel": 5,
"tradeAllowed": true,
"quoteTime": "2027-01-15T08:00:00.000Z",
"quoteBrokerTime": "2027-01-15T10:00:00",
"tickValue": "1",
"contractSize": "100000",
"marginRequired": "1000",
"swapLong": "-1.25",
"swapShort": "0.5",
"swapType": 0,
"profitCurrency": "USD",
"terminalId": "mt4-aaaaaaaaaaaa",
"receivedAt": "2027-01-15T08:00:00.000Z"
},
"meta": {}
}
stopLevel and freezeLevel are in points; marginRequired is per lot in the account's currency.
GET /api/v1/accounts/{account}/markets/{realMarket}/conditions
Authorization: Bearer <readonly key>
HTTP/1.1 409 Conflict
{
"error": {
"code": "cross-broker-market",
"message": "This market is on another broker server than the account. Use the account's own broker server's market.",
"details": { "accountBrokerServer": "EasyForex-Demo", "marketBrokerServer": "EasyForex-Real" }
}
}
Indicators
GET /indicators lists the enabled packages by ID (cursor-paged); GET /indicators/{packageId} describes one.
IDs are exact (quotebridge.macd, not macd); a disabled or unknown package answers 404 unknown-indicator. A
parameter is { id, label, type, default, min, max, lessThan? }, or { id, label, type: "choice", default, choices: [{ value, label }] }. An output's styles are the keys of its styles values, or null.
GET /api/v1/indicators/quotebridge.ma
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": {
"id": "quotebridge.ma",
"version": "1.0.0",
"name": "Moving average",
"description": "<any>",
"placement": "overlay",
"closedBarsOnly": false,
"parameters": [
{ "id": "period", "label": "Period", "type": "integer", "default": 20, "min": 1, "max": 500 },
{ "id": "type", "label": "Type", "type": "choice", "default": "simple", "choices": [{ "value": "simple", "label": "Simple" }, { "value": "exponential", "label": "Exponential" }] }
],
"outputs": [{ "id": "ma", "label": "MA", "render": "line", "styles": null }],
"hash": "<any>"
},
"meta": {}
}
Terminals
Read-only diagnostics: GET /terminals[/{terminalId}] and GET /terminals/{terminalId}/feeds[/{feedId}]. A
terminal's feeds are its own charts' health and quotes, never another terminal's, and never used to route trading.
connected is a hello within the last seven seconds. Percent-encode a feed ID in the path.
GET /api/v1/terminals/{terminal}
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": { "id": "mt4-aaaaaaaaaaaa", "name": "mt4-aaaaaaaaaaaa", "brokerServer": "EasyForex-Demo", "eaVersion": "<any>", "connected": true, "lastHelloAt": "2027-01-15T08:00:00.000Z", "feeds": 1 },
"meta": {}
}
GET /api/v1/terminals/{terminal}/feeds/{feed}
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": {
"id": "[\"mt4-aaaaaaaaaaaa\",\"EURUSD\"]",
"terminalId": "mt4-aaaaaaaaaaaa",
"marketId": "{market}",
"brokerServer": "EasyForex-Demo",
"symbol": "EURUSD",
"digits": 5,
"timeframe": "<any>",
"online": true,
"stale": false,
"instances": 1,
"quoteStatus": "ok",
"recording": true,
"bid": "1.10000",
"ask": "1.10015",
"time": "2027-01-15T08:00:00.000Z",
"brokerTime": "2027-01-15T10:00:00",
"receivedAt": "2027-01-15T08:00:00.000Z"
},
"meta": {}
}
Trading requests
Every change to an account goes through a request: preview it, then execute that exact preview. Demo accounts
trade with Demo trading switched on in the app window's Settings, real accounts only with Real-account trading
switched on there; a real account also needs exactly one chart with
EnableRealTrading, while any chart of a demo account can trade. MetaTrader's own permissions are yours to set; the app never changes them.
| Endpoint | Answer |
|---|---|
POST /requests/preview |
A single-use preview of a place, modify, close or cancel (201). |
POST /requests/{requestId}/execute |
{ "confirm": true }: 202 once dispatched; a repeat answers the same record (200) and sends nothing. |
GET /requests |
The journal of executed requests, oldest first; exact accountId filter; cursor-paged. |
GET /requests/{requestId} |
A request's record, or its preview while still valid. |
POST /requests/{requestId}/review |
{ "note", "acknowledged": true } for an unknown outcome. |
Preview
Every preview names its account. A new order (action: "place") names an exact marketId on the account's broker
server (409 cross-broker-market otherwise), side (buy, sell), type (market, limit, stop) and
lots. A limit or stop needs price and may have a UTC expiry (whole seconds). Stops are either absolute
stopLoss/takeProfit, or a risk plan — never both: { "loss": { "amount" | "balancePercent" | "points": "…" }, "rewardRatio": "…" }, from which the stops are worked out for the given lots, rounded toward entry; a plan that
cannot be met is refused rather than widened. A third form is stop distances: distance: { "stopLoss"?: "…", "takeProfit"?: "…" } in points from the previewed entry (below it for a buy's stop loss, above for a sell's, and the
reverse for a take profit), at least one of them, rounded toward entry the same way (unsatisfiable-distance when a
distance reaches past zero or rounds to the entry). Only one form per order. Estimates exclude fees, swaps and slippage
and are not loss caps.
Existing trades are named by account and ticket: modify (price and expiry of a pending order, stopLoss,
takeProfit; left out stays, null clears, zero is not a clear), close (a whole position) and cancel (a
pending order). There are no lot changes or partial closes. Trades placed outside QuoteBridge can be managed too,
with a warning.
The app reads the account and contract fresh from the account's trading chart (five seconds at most), checks lots against the broker's steps and the smallest of the broker's, the EA's and the app's caps (caps do not stop managing a larger existing trade), tick size, stop and freeze levels and margin, and answers the normalized intent the execute will send. A preview lasts 30 seconds, lives in memory only and is invalidated by a change of settings, account, terminal or chart. Nothing is sent to MetaTrader.
POST /api/v1/requests/preview
Authorization: Bearer <readonly key>
Content-Type: application/json
{ "accountId": "{account}", "action": "place", "marketId": "{market}", "side": "buy", "type": "market", "lots": "0.10" }
HTTP/1.1 403 Forbidden
{ "error": { "code": "insufficient-scope", "message": "This API key may only read. Create a key with the trader scope to send changes.", "details": null } }
POST /api/v1/requests/preview
Authorization: Bearer <trader key>
Content-Type: application/json
{ "accountId": "{account}", "action": "place", "marketId": "{market}", "side": "buy", "type": "market", "lots": "0.10" }
HTTP/1.1 201 Created
{
"data": {
"requestId": "<$requestId>",
"status": "preview",
"createdAt": "2027-01-15T08:00:00.000Z",
"expiresAt": "2027-01-15T08:00:30.000Z",
"accountId": "{account}",
"marketId": "{market}",
"terminalId": "mt4-aaaaaaaaaaaa",
"normalizedIntent": { "action": "place", "ticket": null, "symbol": "EURUSD", "side": "buy", "type": "market", "lots": "0.1", "price": "1.10002", "stopLoss": null, "takeProfit": null, "expiry": null, "slippage": 20 },
"target": null,
"limits": { "broker": "100", "ea": "0.5", "app": "1", "maxLots": "0.5" },
"estimates": "<any>",
"warnings": "<any>"
},
"meta": {}
}
slippage is in points, from Settings. A market order's price is the quote the preview approved; the EA keeps
within that price and slippage. A fill is not guaranteed.
GET /api/v1/requests/{requestId}
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": {
"requestId": "{requestId}",
"status": "preview",
"createdAt": "2027-01-15T08:00:00.000Z",
"expiresAt": "2027-01-15T08:00:30.000Z",
"accountId": "{account}",
"marketId": "{market}",
"terminalId": "mt4-aaaaaaaaaaaa",
"normalizedIntent": "<any>",
"target": null,
"limits": "<any>",
"estimates": "<any>",
"warnings": "<any>"
},
"meta": {}
}
Existing trades
POST /api/v1/requests/preview
Authorization: Bearer <trader key>
Content-Type: application/json
{ "accountId": "{account}", "action": "close", "ticket": "12345" }
HTTP/1.1 201 Created
{
"data": {
"requestId": "<any>",
"status": "preview",
"createdAt": "2027-01-15T08:00:00.000Z",
"expiresAt": "2027-01-15T08:00:30.000Z",
"accountId": "{account}",
"marketId": "{market}",
"terminalId": "mt4-aaaaaaaaaaaa",
"normalizedIntent": "<any>",
"target": {
"ticket": "12345",
"symbol": "EURUSD",
"side": "buy",
"type": "market",
"lots": "0.1",
"openPrice": "1.1",
"openTime": "2023-11-14T20:13:20.000Z",
"stopLoss": null,
"takeProfit": null,
"expiry": null
},
"limits": "<any>",
"estimates": "<any>",
"warnings": "<any>"
},
"meta": {}
}
target is the trade as the preview saw it; if it changes before execute, execute refuses (409 target-changed)
and a new preview is needed.
Execute
Execute re-reads the account and contract and checks again that the approved intent still holds, then records the
request durably before sending it. It answers 202 with status dispatching once sent; the result follows.
At most one request per account is unresolved at a time (409 account-unresolved, also for a preview on that account
while one is); other accounts are independent.
- Never retry an execute to find out what happened, and never preview the same order again for that: GET the
same request ID. A repeated execute answers the existing record (
200) and sends nothing. - Outcomes:
dispatching,succeeded,failed(a definitive refusal, with the broker's code and message) orunknown. Twenty seconds without a definitive result, a send that may have been interrupted, or a restart while dispatching make itunknown. The app never resends anything.
POST /api/v1/requests/{requestId}/execute
Authorization: Bearer <trader key>
Content-Type: application/json
{ "confirm": true }
HTTP/1.1 202 Accepted
{
"data": {
"requestId": "{requestId}",
"status": "dispatching",
"reviewed": false,
"origin": "api-key",
"createdAt": "2027-01-15T08:00:00.000Z",
"updatedAt": "2027-01-15T08:00:00.000Z",
"accountId": "{account}",
"marketId": "{market}",
"terminalId": "mt4-aaaaaaaaaaaa",
"normalizedIntent": "<any>",
"target": null,
"limits": "<any>",
"estimates": "<any>",
"warnings": "<any>",
"result": null,
"events": []
},
"meta": {}
}
POST /api/v1/requests/{requestId}/execute
Authorization: Bearer <trader key>
Content-Type: application/json
{ "confirm": true }
HTTP/1.1 200 OK
{
"data": {
"requestId": "{requestId}",
"status": "dispatching",
"reviewed": false,
"origin": "api-key",
"createdAt": "2027-01-15T08:00:00.000Z",
"updatedAt": "2027-01-15T08:00:00.000Z",
"accountId": "{account}",
"marketId": "{market}",
"terminalId": "mt4-aaaaaaaaaaaa",
"normalizedIntent": "<any>",
"target": null,
"limits": "<any>",
"estimates": "<any>",
"warnings": "<any>",
"result": null,
"events": []
},
"meta": {}
}
A record's result is the definitive or latest result: { status, ticket, code, message }, ticket null when
MetaTrader confirmed none. events is the append-only evidence: result, unknown (with its reason) and
review events, each with its time at. Records are kept indefinitely.
GET /api/v1/requests?accountId={account}
Authorization: Bearer <readonly key>
HTTP/1.1 200 OK
{
"data": [
{
"requestId": "{requestId}",
"status": "dispatching",
"reviewed": false,
"origin": "api-key",
"createdAt": "2027-01-15T08:00:00.000Z",
"updatedAt": "2027-01-15T08:00:00.000Z",
"accountId": "{account}",
"marketId": "{market}",
"terminalId": "mt4-aaaaaaaaaaaa",
"normalizedIntent": "<any>",
"target": null,
"limits": "<any>",
"estimates": "<any>",
"warnings": "<any>",
"result": null,
"events": []
}
],
"meta": { "count": 1, "limit": 100, "nextCursor": null }
}
Review
An unknown outcome blocks its account, previews included, until a definitive result arrives or someone reviews it. First check
MetaTrader's open orders and account history; then review with a note of 8–500 characters and
acknowledged: true. Review works with Demo trading off. It does not invent an outcome (the status stays unknown,
reviewed becomes true) and never resends; a late result can still settle the record.
POST /api/v1/requests/{requestId}/review
Authorization: Bearer <trader key>
Content-Type: application/json
{ "note": "Checked MT4 open orders and history", "acknowledged": true }
HTTP/1.1 409 Conflict
{ "error": { "code": "request-not-unknown", "message": "Only a request whose outcome is unknown can be reviewed.", "details": null } }
Screenshots
GET /screenshots/feeds shows that the app is running normally: it switches your own app window to the Feeds
view, captures it and answers the image with the window's health. The window is left on Feeds.
GET /screenshots/charts/{marketId} does the same with one market's chart. The window is captured
whether it is shown, minimised or hidden in the tray, without being seen or focused, and is left that way. It needs
a trader key, because it changes the window; a readonly key answers 403 insufficient-scope.
| Field | Value |
|---|---|
view |
feeds, or chart for a market's chart |
window |
How the window was: visible, minimised or hidden (in the tray). |
width, height, scale |
The image's size in the window's own physical pixels, at its display's scale. |
live |
The page's link to the app: live, connecting, reconnecting or session-ended. |
capturedAt |
When the image was taken. |
contentType, png |
image/png, and the image in base64. |
GET /api/v1/screenshots/feeds
Authorization: Bearer <trader key>
HTTP/1.1 200 OK
{
"data": { "view": "feeds", "window": "hidden", "width": 1800, "height": 1200, "scale": 1.5, "live": "live", "capturedAt": "2027-01-15T08:00:00.000Z", "contentType": "image/png", "png": "<any>" },
"meta": {}
}
With ?format=png the answer is the image itself (Content-Type: image/png) and the fields are headers:
X-QuoteBridge-View, -Window, -Width, -Height, -Scale, -Live and -Captured-At.
One screenshot is taken at a time; others wait their turn. Each has ten seconds.
| Refusal | When |
|---|---|
409 window-busy |
A dialog is open in the window (an order, close, modify or review dialog, or the setup screen). The message names it. Nothing is switched and no image is taken. |
409 no-feed |
Only for a market's chart: no terminal streams the market. |
503 window-unavailable |
There is no window to capture: the app is still starting, the window is being recreated after a crash, or the server runs without the app (a development run in a browser tab). |
504 screenshot-timeout |
The window was not captured within ten seconds; so too while the Terminals view's Remove confirmation is open, which stops the page answering. A hidden or minimised window is put back. |
GET /api/v1/screenshots/feeds
Authorization: Bearer <readonly key>
HTTP/1.1 403 Forbidden
{ "error": { "code": "insufficient-scope", "message": "A screenshot switches the app window's view: use a key with the trader scope.", "details": null } }
A market's chart
GET /screenshots/charts/{marketId} switches the window to the Charts view on the market's best source feed, chosen
as for its quote: a connected terminal before a quiet one, then the newest broker quote. The window is
left on that chart, and its chart remembers that feed, as when you pick it yourself. The chart is drawn as the window
remembers it: its period, chart type, Ask line and indicators. Trade lines are shown, unless you turned them off on
the chart already open. The image is taken once the chart has drawn its candles, every indicator's values and, with
trade lines shown, the trades. After six seconds it is taken with what the chart shows then (still loading, say).
With no live link to the app, the chart is captured loading.
The answer has the screenshot's fields with view: "chart" and the market's marketId,
brokerServer and symbol. With ?format=png these are the headers X-QuoteBridge-Market-Id, -Broker-Server
and -Symbol too.
GET /api/v1/screenshots/charts/{market}
Authorization: Bearer <trader key>
HTTP/1.1 200 OK
{
"data": { "view": "chart", "marketId": "{market}", "brokerServer": "EasyForex-Demo", "symbol": "EURUSD", "window": "hidden", "width": 1800, "height": 1200, "scale": 1.5, "live": "live", "capturedAt": "2027-01-15T08:00:00.000Z", "contentType": "image/png", "png": "<any>" },
"meta": {}
}
An unknown market answers 404 unknown-market. A market the app knows only from a recording or MetaTrader's HST
files, with no terminal streaming it, answers 409 no-feed. A feed whose terminal has gone quiet is still listed, and
its chart is captured as it shows, with its offline notice. The other refusals, the queue and the ten seconds are as
for Feeds.
Error codes
| Code | Status | Meaning |
|---|---|---|
invalid-parameter, unknown-parameter, invalid-time, invalid-timeframe, invalid-format, invalid-cursor, invalid-path, invalid-ticket, invalid-request-id |
400 | A query or path value is malformed or not taken. |
invalid-json, invalid-input, unknown-property, invalid-action, confirmation-required |
400 | A body is malformed, has an unknown field, or execute lacks { "confirm": true }. |
invalid-indicator-parameter |
400 | An indicator parameter is unknown or out of range. |
unauthenticated |
401 | No valid key or session. |
insufficient-scope, forbidden-origin, not-authorized |
403 | The key may only read, or the session may not change this. |
trading-disabled, real-trading-disabled, demo-only, trading-not-allowed |
403 | Demo trading is off (a demo account), Real-account trading is off (a real account), it is not known whether the account is demo or real, or the broker does not allow trading the symbol. |
unknown-route, unknown-market, unknown-account, unknown-position, unknown-order, unknown-terminal, unknown-feed, unknown-indicator, request-not-found, no-candles, no-history |
404 | No such resource. |
method-not-allowed |
405 | Use the method the message names. |
cross-broker-market |
409 | The market is not on the account's broker server. |
preview-unavailable, preview-expired, preview-invalidated, settings-changed, executor-changed, identity-changed, limits-changed, target-changed, price-changed, estimates-changed, expiry-conversion-changed, stale-snapshot, stale-quote |
409 | The preview no longer holds: preview again. |
account-unresolved |
409 | The account has a request without a definitive result: GET it, and review it if unknown. |
executor-zero, executor-multiple, executor-not-ready, executor-real-account, executor-unavailable |
409 | The account has no single ready trading chart, or is a real account none of whose charts has EnableRealTrading (executor-real-account). |
window-busy |
409 | A dialog is open in the app window; no screenshot was taken. |
no-feed |
409 | No terminal streams the market, so there is no chart of it to capture. |
request-not-unknown, account-changed, invalid-response, invalid-conditions, invalid-quote, invalid-target, clock-rule-required, target-unavailable, terminal-updating, result-mismatch |
409 | The request conflicts with the current state, or the terminal's data cannot be used. |
invalid-decimal, numeric-range, invalid-side, invalid-type, invalid-market-fields, invalid-volume, invalid-tick, invalid-stops, invalid-entry, invalid-expiry, ambiguous-expiry, conflicting-stops, invalid-risk, invalid-distance, unsatisfiable-distance, unsatisfiable-risk, risk-unavailable, insufficient-margin, frozen-target, wrong-target-kind, invalid-slippage, invalid-review |
422 | A trading value is invalid for this account and market. |
indicator-failed |
422 | The indicator's calculation failed; details.reason says why. |
body-too-large, unsupported-media-type |
413, 415 | Send JSON bodies of at most 16 KiB. |
quote-unavailable, feed-required, unavailable-source, account-unavailable, journal-unavailable, trading-unavailable, trading-stopped |
503 | The source, journal or engine is unavailable now. |
window-unavailable |
503 | There is no app window to capture now. |
read-failed |
503 | The terminal refused an account or conditions read, such as for an unknown symbol; details.reason gives the EA's reason. |
read-timeout |
504 | The terminal did not answer within five seconds. Not an order failure. |
screenshot-timeout |
504 | The app window was not captured within ten seconds. |
internal-error |
500 | Check the app's logs. |
Chart history
Candles, indicator values, history and exports show a timeframe's bars from these sources, oldest to newest:
- Saved history: the app's own copy in its database, for bars older than the terminal's history file.
- MetaTrader's history file (
<symbol><minutes>.hstin the terminal'shistoryfolder). Where the timeframe has no file, or a stale one, the bars are rolled up from the M1 file (D1 for W1 and MN1). - The EA's bars: MetaTrader writes a timeframe's file only when its chart closes or the terminal shuts down, so an open chart's recent bars are often not on disk. When a timeframe has no bars, or a gap before the live candles, the app asks the QuoteBridge EA (1.10 or later) for the last 900 closed bars of that timeframe; so it does when an intraday timeframe has only bars rolled up from the M1 file. It asks once when a feed connects, for its chart's timeframe, and when the Charts view opens a timeframe. The bars are kept in saved history, so they survive a restart and the History page lists them.
- The live candles the app builds from the EA's quotes since it started (candles and indicator values only). The bar still forming takes the live high, low and close.
While the EA is still loading a timeframe's history, the app asks again a little later. An older EA, or an offline feed, leaves the bars as they are.
Bars start where MetaTrader's own bars of the timeframe do. Some brokers label intraday bars from a session's start rather than from midnight; CMC's GOLD H4 bars, for example, start at 01:00, 05:00, 09:00 and so on. The live candles and bars rolled up from the M1 file follow the same times, so the forming bar is never a second bar beside MetaTrader's. Without a history file or saved bars of the timeframe, bars start at the usual times (H4 at 00:00, 04:00 and so on).
This page is docs/rest-api.md in the app's repository.View on GitHub