# MetaKit > REST API for MetaTrader 5. Connect MT5 accounts programmatically, read live > and historical trade data, place and manage trades, mirror one account onto > another (copy trading), and receive webhooks. Each connected account runs an > isolated terminal; the API is the only interface you need. This file is written for LLMs and coding agents integrating MetaKit into another application. Every endpoint, field name, and constraint below is taken from the implementation, not from marketing copy. **This file is complete and self-contained. It is the whole public API — do not go looking for more.** There are no per-topic documentation pages: the only human-readable docs live at a single URL (`https://app.metakit.cloud/docs`) as a tabbed client-rendered app, and paths like `/docs/accounts` or `/docs/webhooks` **do not exist and will 404**. Fetching `/docs` with `Accept: text/markdown` (or fetching `/docs.md`) returns this very file, so there is nothing there this file lacks. If something you need appears missing, it is genuinely not in the public API. - Base URL: `https://api.metakit.cloud` - All public endpoints live under `/v1` - Dashboard: https://app.metakit.cloud - Human-readable docs (single page, JS-rendered — this file is better for agents): https://app.metakit.cloud/docs --- ## Authentication Every `/v1` request needs an API key. Create one in the dashboard under Settings → API keys. The key is shown once at creation and stored hashed. Either header works: ``` Authorization: Bearer mk_live_xxxxxxxxxxxxxxxx x-api-key: mk_live_xxxxxxxxxxxxxxxx ``` Missing or invalid key: ```json { "error": { "code": "unauthorized", "message": "Missing API key. Provide it as a Bearer token." } } ``` Keys are scoped to the user who created them. All data access is automatically limited to that user's accounts and copiers — there is no cross-tenant access and no account_id you can pass to escape it. ### Key scopes Every key carries a scope, chosen when it is created: | Scope | Permits | |------------|----------------------------------------------------------------| | `readonly` | `GET` requests only — read accounts, trades, analytics, market data | | `full` | Everything, including `POST`/`PATCH`/`PUT`/`DELETE` | A write request made with a `readonly` key is rejected: ```json { "error": { "code": "insufficient_scope", "message": "This API key is read-only and cannot perform write operations. Create a key with the \"full\" scope." } } ``` Prefer `readonly` whenever the integration only reads — notably when handing a key to an AI agent or any third-party tool. Note this is a *separate* gate from an account's slot tier, which happens to use the same two words. Both apply: a `full` key still cannot place trades on an account connected to a `readonly` slot (that is `403 account_readonly`, see "Trading" below). ### Key expiry A key may have an expiry date. Once past it, every request fails: ```json { "error": { "code": "key_expired", "message": "This API key expired on 2026-10-27T23:59:59.000Z. Create a new one." } } ``` Keys created without an expiry never expire and stay valid until revoked. --- ## Conventions **Ids.** Public objects use small integer ids (`1`, `2`, `3`), not UUIDs. **Addressing.** Everything belonging to an account is nested under it: `/v1/accounts/{id}/positions`, `/v1/accounts/{id}/candles`, and so on. The account is never passed as a query parameter. Query params are reserved for filtering and pagination (`symbol`, `from`, `to`, `page`, `limit`, `range`). **Casing.** Request and response bodies use `snake_case`. (The internal dashboard API uses camelCase; do not mix them up when reading source.) **Timestamps.** ISO 8601 UTC, e.g. `2026-07-28T09:41:00.000Z`. Date query params accept ISO 8601 or a plain `YYYY-MM-DD`. **Money.** Plain numbers in the account's own currency. There is no minor-unit (cents) convention — `1234.56` means 1234.56 USD if the account is USD. **Errors.** Always this shape: ```json { "error": { "code": "invalid_request", "message": "symbol is required" } } ``` This is the **complete** set of `code` values the public API emits. Branch on `code`, never on the message text (messages may be reworded). | Status | Code | Meaning | |--------|--------------------------|--------------------------------------------------| | 400 | `invalid_request` | Bad or missing parameters | | 401 | `unauthorized` | Missing or invalid API key | | 401 | `key_expired` | The key is past its expiry date | | 403 | `insufficient_scope` | A `readonly` key attempted a write | | 403 | `account_readonly` | Trading on an account connected on a `readonly` slot | | 402 | `no_slots_available` | No free slot of the requested tier — buy one | | 402 | `payment_method_required`| No saved card; add one in the dashboard first | | 404 | `not_found` | Object does not exist, or is not yours (also: unknown symbol, position or order ticket) | | 409 | `account_not_running` | Read endpoint: the account's terminal is not connected yet | | 409 | `account_not_connected` | Trading endpoint: account status is not `connected` | | 409 | `idempotency_in_progress`| Same `Idempotency-Key` is still executing — retry shortly | | 409 | `slot_occupied` | Slot still has an account connected | | 409 | `already_canceled` | Slot was already released | | 422 | `invalid_volume` | Below min, above max, or not a multiple of the volume step | | 422 | `invalid_stops` | SL/TP (or a pending price) on the wrong side of price, or inside `stops_level` | | 422 | `symbol_not_tradeable` | `trade_mode` forbids this side, or the market is closed | | 422 | `order_rejected` | The broker refused; body carries the raw `retcode` | | 500 | `internal_error` | Unexpected server fault | | 502 | `upstream_error` | The trading terminal failed or is unreachable | | 504 | `terminal_timeout` | No broker answer within 10s — the order MAY have executed | There is no generic `conflict` code — every 409 carries one of the specific codes above. A `502` almost always means the account's terminal is not `connected`. Check `status` on the account before retrying. **Pagination.** List endpoints accept `page` (1-based) and `limit` (default 25, max 100) and return: ```json { "data": [], "page": 1, "limit": 25, "total": 0, "total_pages": 0 } ``` Note: the tick and candle endpoints do NOT use this envelope — see below. --- ## Core concept: accounts and slots A **slot** is a paid seat. Connecting an MT5 account occupies one slot; disconnecting frees it. Two tiers: - `readonly` — read data only. Trade endpoints are rejected. - `full` — read plus place/modify/close trades. Required to be a copier *follower*. Buy slots in the dashboard (Billing), or programmatically via `POST /v1/slots` — which requires a saved card, since an API client cannot render a card form. See "Billing — slots" below. ### Account status lifecycle These are **all** the values `status` can take: | Status | Terminal? | Meaning | |-----------------------|-----------|--------------------------------------------------| | `provisioning` | no | Preparing a broker-specific terminal image. First connect for a white-label broker only; can take several minutes. | | `starting` | no | Connecting to the broker. Data endpoints return 502. | | `connected` | no | Ready. All endpoints work. | | `error` | yes | Could not connect, or lost the connection. | | `invalid_credentials` | yes | Broker rejected the login. Fix credentials and PATCH. | | `disconnected` | yes | Stopped. | **Connecting takes time.** After `POST /v1/accounts` the account is `provisioning` and/or `starting` — anywhere from ~30 seconds to several minutes on a first connect. Treat both as "still working", not as failure. Poll `GET /v1/accounts/{id}` until `status` is `connected`, or subscribe to the `account.connected` webhook instead of polling. Do not write `if (status !== 'connected') fail()` — that breaks on every fresh connect. Wait for a terminal status. --- ## Endpoints ### Accounts ``` GET /v1/accounts List accounts (paginated) GET /v1/accounts/{id} One account, with live figures POST /v1/accounts Connect an MT5 account (occupies a slot) PATCH /v1/accounts/{id} Update name/broker/server/password DELETE /v1/accounts/{id} Disconnect and free the slot ``` **Create:** ```bash curl -X POST https://api.metakit.cloud/v1/accounts \ -H "Authorization: Bearer $METAKIT_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "My Account", "number": 40317, "password": "investor-or-master-password", "broker_id": 210, "server": "ICMarketsSC-Demo", "type": "full" }' ``` `broker_id` comes from `GET /v1/brokers`. `server` must exactly match a broker server name (e.g. `ICMarketsSC-Demo`). **Account object:** ```json { "id": 2, "created_at": "2026-07-10T12:00:00.000Z", "updated_at": "2026-07-28T09:00:00.000Z", "platform": "mt5", "type": "full", "account_name": "My Account", "account_number": 40317, "broker_id": 210, "server": "ICMarketsSC-Demo", "status": "connected", "last_ping": "2026-07-28T09:41:00.000Z", "client_name": "John Trader", "mode": "demo", "leverage": 100, "currency": "USD", "balance": 406944.0, "credit": 0, "equity": 406944.0, "free_margin": 393396.0, "used_margin": 13548.0, "open_trades": 3, "pending_orders": 1, "daily_profit": 120.5, "weekly_profit": 940.2, "monthly_profit": 6944.0, "total_profit": 6944.0 } ``` Live fields (`balance` through `total_profit`) are only populated when `status` is `connected`; otherwise they are `null` or `0`. --- ### Market data ``` GET /v1/accounts/{id}/symbols Tradable symbols (paginated) GET /v1/accounts/{id}/symbols/{symbol} Full spec for one symbol GET /v1/accounts/{id}/quote?symbol= Current bid / ask / time for one symbol GET /v1/accounts/{id}/positions Open positions (paginated) GET /v1/accounts/{id}/orders Pending orders (paginated) GET /v1/accounts/{id}/deals Closed deal history (paginated) GET /v1/accounts/{id}/ticks Raw bid/ask ticks GET /v1/accounts/{id}/candles OHLC bars ``` The account is always a path segment. There is no `?account_id=` form — every resource lives under `/v1/accounts/{id}/`. #### Symbol spec Everything MT5 exposes through SymbolInfo that sizing and order validation need. Use it to turn cash-at-risk into lots exactly, and to check stops before sending an order. ``` GET /v1/accounts/{id}/symbols/XAUUSD.m ``` ```json { "symbol": "XAUUSD.m", "digits": 2, "contract_size": 100, "volume_min": 0.01, "volume_max": 50, "volume_step": 0.01, "tick_size": 0.01, "tick_value": 1.0, "stops_level": 30, "freeze_level": 0, "trade_mode": "full", "currency_base": "XAU", "currency_profit": "USD", "currency_margin": "USD", "bid": 2331.40, "ask": 2331.65, "spread": 25, "min_lot": 0.01, "max_lot": 50, "lot_step": 0.01, "stop_level": 30, "base_currency": "XAU", "profit_currency": "USD", "margin_currency": "USD" } ``` - `stops_level` is the minimum distance (in **points**, i.e. `10^-digits`) between price and a stop/target or a pending price. The most common reason a broker rejects an order — the API checks it before sending. - `trade_mode`: `full`, `long_only`, `short_only`, `close_only`, `disabled`. - The `min_lot` / `max_lot` / `lot_step` / `stop_level` / `*_currency` fields are the same values under the API's original names; both spellings are stable. - `bid` / `ask` here are from the symbol snapshot and can lag; use `/quote` right before placing. #### Quote ``` GET /v1/accounts/{id}/quote?symbol=EURUSD ``` ```json { "symbol": "EURUSD", "bid": 1.16401, "ask": 1.16413, "last": 0, "time": "2026-09-22T14:03:10.000Z" } ``` `last` is 0 on symbols without a last-trade feed (most FX). `time` is the tick's own timestamp (server clock, see Timestamps). #### Ticks ``` GET /v1/accounts/{id}/ticks?symbol=EURUSD&from=2026-07-28T09:00:00Z&to=2026-07-28T10:00:00Z ``` | Param | Notes | |------------|----------------------------------------------------------------| | `symbol` | Required. | | `from` | ISO 8601 or `YYYY-MM-DD`. Defaults to 24h ago. | | `to` | Optional. **Presence switches to a true range query.** | | `count` | Used only WITHOUT `to`. Default 500, max 50000. | Two distinct modes: - **`from` + `to`** — every tick in the window. - **`from` + `count`** — N ticks starting at `from`. Because ticks arrive irregularly, this covers an unpredictable *span* of time. `to` without `from` is a 400. `to` must be after `from`. Response is **not** the pagination envelope: ```json { "data": [ { "time": "2026-07-28T09:00:00.123Z", "bid": 1.16421, "ask": 1.16433, "last": 0, "volume": 0 } ], "symbol": "EURUSD", "count": 1, "truncated": false } ``` When `truncated` is `true`, the result hit the 50000 cap and a `message` field explains it. Narrow the window or use candles. #### Candles ``` GET /v1/accounts/{id}/candles?symbol=EURUSD&timeframe=H1&count=500 ``` | Param | Notes | |-------------|--------------------------------------------------------------------| | `symbol` | Required. | | `timeframe` | `M1 M5 M15 M30 H1 H4 D1 W1 MN1`. Default `M1`. | | `from`/`to` | Both → range. `from` only → `count` bars from that point. | | `count` | Default 500, max 5000. Used when `to` is absent. | With neither `from` nor `to`, returns the most recent `count` bars — the usual case for drawing a chart. ```json { "data": [ { "time": "2026-07-28T09:00:00.000Z", "open": 1.16401, "high": 1.16455, "low": 1.16388, "close": 1.16433, "tickVolume": 1843, "spread": 12, "realVolume": 0 } ], "symbol": "EURUSD", "timeframe": "H1", "count": 1, "truncated": false } ``` **Prefer candles over ticks for charting.** One day of EURUSD ticks is hundreds of thousands of rows; the same day is 1440 M1 bars. `realVolume` is usually `0` on FX — brokers rarely report it. `time` is the bar's OPEN. #### Deals ``` GET /v1/accounts/{id}/deals?from=2026-01-01&to=2026-07-28&page=1&limit=100 ``` Deals are MT5's ledger: opening a trade produces an `entry: "in"` deal and closing it an `entry: "out"` deal, both sharing a `positionId`. Realised P/L lands on the `out` deal; `commission`, `swap` and `fee` can sit on either leg, so net a trade by summing all four across the group. Deal rows are camelCase (`positionId`, `time`, `symbol`, `type`, `entry`, `volume`, `price`, `profit`, `commission`, `swap`, `fee`, `reason`, `magic`, `comment`); the reconstructed trades returned by `/performance` use snake_case (`position_id`). To reconstruct whole trades yourself, group by `positionId` — or just use `/performance`, which does this for you. --- ### Analytics ``` GET /v1/accounts/{id}/analyses Lifetime aggregates from deal history GET /v1/accounts/{id}/performance Full analytics payload (charts + risk) ``` `/performance?range=1m|3m|6m|12m|all` (default `12m`) returns everything the dashboard's performance page renders: KPIs, equity curve, drawdown, and breakdowns by symbol / weekday / trading session / hold time. Key fields: ```json { "range": "12m", "net_profit": 50368.42, "growth": 201.47, "win_rate": 53.21, "closed_trades": 778, "profit_factor": 1.61, "expectancy": 64.74, "max_drawdown": -2781.4, "max_drawdown_pct": -5.3, "sharpe": 5.4, "sessions": [{ "date": "2026-07-28", "net": 412.5, "cumulative": 412.5, "drawdown": 0 }], "timeline": [{ "date": "2026-07-29", "net": 0, "cumulative": 412.5, "drawdown": 0 }], "long": { "label": "Long", "net": 32628.1, "trades": 415, "wins": 232 }, "short": { "label": "Short", "net": 17740.3, "trades": 363, "wins": 182 }, "by_symbol": [], "by_weekday": [], "by_hold_time": [], "session_heat": [], "recent_positions": [] } ``` **Two series, different purposes — this trips people up:** - `sessions` — one entry per **trading day** (days that actually closed a trade). Sharpe and `profitable_sessions` are computed from this. - `timeline` — the same curve padded to **every calendar day** (`net: 0`, `cumulative`/`drawdown` carried forward). Use this for charts so idle stretches take proportional width. Do not compute statistics from `timeline` — the zero-days shrink the standard deviation and inflate Sharpe. `max_drawdown_pct` is measured against the **equity** high-water mark, so it is always within `[-100, 0]`. --- ### Equity monitors A monitor watches one account and fires an alert when a threshold is crossed. ``` GET /v1/monitors List monitors (paginated) GET /v1/monitors/{id} One monitor GET /v1/monitors/{id}/events Alert history (paginated) POST /v1/monitors Create PATCH /v1/monitors/{id} Update, pause, or re-arm DELETE /v1/monitors/{id} Delete (also deletes its history) ``` List filters: `?account_id=2`, `?status=armed`. **Limits.** One monitor per `readonly` account, three per `full` account. Exceeding it returns `409 monitor_limit_reached`. Paused monitors still count — they occupy a slot you can re-arm at any time. **Metrics.** What the monitor watches: | `metric` | Fires on | Threshold unit | |---|---|---| | `equity` | Absolute equity | Account currency | | `equity_percent` | Change from the equity captured when armed | Percent (negative for a loss) | | `drawdown` | Fall from the highest equity since arming | Percent, 0–100 | | `margin_level` | Broker margin level | Percent | `comparator` is `above` or `below`. It is ignored for `drawdown`, which always compares upward — a drawdown *exceeding* the threshold — and is stored as `above` whatever you send. **Re-arm modes.** What happens after it fires: | `rearm_mode` | Behaviour | |---|---| | `once` | Alerts once, then stays `triggered` until you PATCH `status: "armed"` | | `recovery` | Re-arms automatically once the value recovers past the threshold (with a 2% hysteresis buffer so a value sitting on the boundary cannot flap) | | `cooldown` | Re-alerts while still breached, at most once per `cooldown_minutes` | **Channels.** One channel per monitor. `channel_config` is write-only — it is never returned by any endpoint, so a leaked API key cannot become a leaked Slack webhook. | `channel` | Required `channel_config` | |---|---| | `webhook` | `url` | | `slack` | `url` (must start `https://hooks.slack.com/`) | | `discord` | `url` (must start `https://discord.com/api/webhooks/`) | | `telegram` | `bot_token` and `chat_id` | **Create:** ```bash curl -X POST https://api.metakit.cloud/v1/monitors \ -H "Authorization: Bearer $METAKIT_KEY" \ -H "Content-Type: application/json" \ -d '{ "account_id": 2, "name": "Drawdown guard", "metric": "drawdown", "threshold": 10, "rearm_mode": "recovery", "channel": "slack", "channel_config": { "url": "https://hooks.slack.com/services/T00/B00/xxx" } }' ``` **Monitor object:** ```json { "id": 4, "account_id": 2, "name": "Drawdown guard", "metric": "drawdown", "comparator": "above", "threshold": 10, "rearm_mode": "recovery", "cooldown_minutes": 30, "channel": "slack", "status": "armed", "last_value": 3.2, "last_checked_at": "2026-07-29T10:14:40.000Z", "last_triggered_at": null, "trigger_count": 0, "created_at": "2026-07-20T09:00:00.000Z", "updated_at": "2026-07-29T10:14:40.000Z" } ``` `status` is `armed`, `triggered`, `paused`, or `error` (the last delivery failed — evaluation continues, so it recovers on its own once the endpoint works). Re-arm a triggered monitor with `PATCH {"status": "armed"}`. **Delivered payload.** A `webhook`-channel monitor POSTs this to your URL, with the header `X-MetaKit-Event: monitor.triggered`: ```json { "event": "monitor.triggered", "timestamp": "2026-07-29T10:14:55.301Z", "monitor": { "id": 4, "name": "Drawdown guard", "metric": "drawdown", "comparator": "above", "threshold": 10 }, "account": { "id": 2, "name": "My Account", "currency": "USD", "equity": 10800, "balance": 12000 }, "value": 12.4, "message": "Drawdown reached 12.40% (limit 10.00%)" } ``` ⚠️ Unlike the registered-webhook events documented under **Webhooks** above, monitor alerts are **not** HMAC-signed — there is no `X-MetaKit-Signature` header on this delivery. Authenticate it by keeping the URL itself secret (a high-entropy path or query token you generate), and re-read the account from the API before acting on anything that matters. Slack, Discord, and Telegram channels receive platform-native formatting (Block Kit, a rich embed, and HTML respectively) rather than this envelope. **Evaluation cadence.** Monitors are evaluated about every 20 seconds, and only while the account is `connected`. An account that is `starting` or in `error` is skipped without changing monitor state, so monitoring resumes by itself when the terminal reconnects. --- ### Trading ``` POST /v1/accounts/{id}/orders Open a market position or place a pending order PATCH /v1/accounts/{id}/positions/{ticket} Move stop loss / take profit DELETE /v1/accounts/{id}/positions/{ticket} Close a position, fully or partially DELETE /v1/accounts/{id}/orders/{ticket} Cancel a pending order ``` **Guards — checked in this order, before the terminal is touched:** | HTTP | code | when | |------|-------------------------|------------------------------------------------------------| | 403 | `insufficient_scope` | the API key is `readonly` | | 404 | `not_found` | account is not yours; unknown symbol; ticket not open | | 403 | `account_readonly` | the account's `type` is `readonly` (investor password) | | 409 | `account_not_connected` | account `status` is anything but `connected` | | 422 | `invalid_volume` | below `volume_min`, above `volume_max`, or off `volume_step` — **never rounded** | | 422 | `invalid_stops` | SL/TP or pending price on the wrong side, or inside `stops_level` | | 422 | `symbol_not_tradeable` | `trade_mode` forbids it, or the broker says the market is closed | | 422 | `order_rejected` | broker refused; carries the raw `retcode` | | 504 | `terminal_timeout` | no broker answer within 10 s | Both the key scope AND the slot tier must be `full`. A `readonly` key cannot trade any account; a `full` key cannot trade a `readonly` account. **Idempotency.** Send `Idempotency-Key: ` on every write. If the same key arrives again within 24 hours (same account and operation), the original response body is returned with HTTP **200** and the header `Idempotent-Replayed: true`, and the terminal is not touched. That makes a network retry safe and rules out a double fill. Every outcome is stored — including `422` and `504` — so a retry after a timeout replays the timeout rather than sending a second order. If the first request is still executing you get `409 idempotency_in_progress`; wait and retry with the same key. **Synchronous.** Market orders block until the broker answers (normally well under a second) with a hard 10 s cap → `504 terminal_timeout`. After a timeout the order **may** have filled: check `/positions` (or wait for the `position.opened` webhook) before deciding to retry without the key. #### Place an order ``` POST /v1/accounts/{id}/orders Idempotency-Key: idea-7f3a ``` ```json { "symbol": "XAUUSD.m", "side": "buy", "type": "market", "volume": 0.12, "price": null, "sl": 2310.50, "tp": 2362.00, "deviation": 20, "comment": "TickerAI idea 7f3a", "expiration": null } ``` | Field | Notes | |--------------|-----------------------------------------------------------------------| | `side` | `buy` or `sell` | | `type` | `market` (default), `limit`, or `stop` | | `volume` | lots, already a multiple of `volume_step` — off-step is `422 invalid_volume` | | `price` | required for `limit`/`stop`, ignored for `market` | | `sl` / `tp` | optional; `0` or `null` = none | | `deviation` | max slippage in points for market fills (default 20) | | `comment` | max 31 characters (an MT5 limit) | | `expiration` | ISO 8601, pending orders only; omitted = good-till-cancelled | A `limit` buys below / sells above the market, a `stop` buys above / sells below — the pending price must be on that side and at least `stops_level` points away, or you get `422 invalid_stops` before anything is sent. **Response — `201`:** ```json { "status": "filled", "order_ticket": 48812231, "position_ticket": 48812231, "deal_ticket": 91002817, "fill_price": 2331.42, "volume": 0.12, "sl": 2310.50, "tp": 2362.00, "retcode": 10009, "retcode_message": "Request completed", "time": "2026-09-22T14:03:11.000Z" } ``` `status` is `filled` for market orders and `placed` for pending ones (then `position_ticket`, `deal_ticket` and `fill_price` are `null`, and `price` / `expiration` echo the request). `position_ticket` equals the order ticket on hedging accounts; on netting accounts it is the symbol's single position. **Broker rejection — `422`, never a 5xx:** ```json { "error": { "code": "order_rejected", "message": "There is not enough money to complete the request" }, "status": "rejected", "retcode": 10019, "retcode_message": "There is not enough money to complete the request" } ``` `retcode` is MT5's raw trade-server code, `retcode_message` its documented text. Rejections the API can name get the more specific code: `10014`/`10038` → `invalid_volume`, `10016` → `invalid_stops`, `10017`/`10018`/`10042`–`10044` → `symbol_not_tradeable`; everything else is `order_rejected`. Common ones: `10004` requote, `10019` not enough money, `10021` off quotes, `10022` invalid expiration, `10027` autotrading disabled in the terminal, `10031` no connection. #### Modify and close ``` PATCH /v1/accounts/{id}/positions/{ticket} { "sl": 2320.0, "tp": 2362.0 } DELETE /v1/accounts/{id}/positions/{ticket} { "volume": 0.06, "deviation": 20 } DELETE /v1/accounts/{id}/orders/{ticket} ``` - `PATCH` takes `sl` and/or `tp`; a field you omit keeps its current value, `0` or `null` clears it. Stops are validated against the current close price (bid for a long, ask for a short) and `stops_level`. - `DELETE` on a position: omit `volume` (or send no body) for a full close; a partial `volume` must be a multiple of `volume_step` and at most the open volume. The response `status` is `closed` or `partially_closed` (with `remaining_volume`). - `DELETE` on a pending order cancels it: `status` is `cancelled`. All three return the same shape as the order endpoint (`status` is `modified` / `closed` / `partially_closed` / `cancelled`), and all accept `Idempotency-Key`. The corresponding webhooks (`position.opened`, `position.modified`, `position.closed`, `order.placed`, `order.filled`, `order.cancelled`, `order.expired`) fire from the terminal itself, so they also cover trades placed outside the API — see Webhooks. --- ### Copiers (copy trading) A copier mirrors a **source** (master) account onto a **follower**. The follower must be a `full` account. ``` GET /v1/copiers List copiers (paginated) GET /v1/copiers/{id} One copier GET /v1/copiers/{id}/performance Copy fidelity, latency, slippage, P/L POST /v1/copiers Create PATCH /v1/copiers/{id} Update rules/state PUT /v1/copiers/{id}/risk Risk settings only PUT /v1/copiers/{id}/symbol-map Bulk symbol mapping DELETE /v1/copiers/{id} Delete (leaves copied positions OPEN) ``` **States:** - `on` — full replication: opens, closes, SL/TP, pending orders. - `monitor` — closes only. Winds the link down without opening anything new. - `off` — paused. Nothing replicates; existing follower positions stay open. **Lot sizing (`lot_mode`):** - `fixed` — always `lot_value` lots. - `multiplier` — source volume × `lot_value`. - `proportional` — scaled by the follower/source balance ratio. ```bash curl -X POST https://api.metakit.cloud/v1/copiers \ -H "Authorization: Bearer $METAKIT_KEY" \ -H "Content-Type: application/json" \ -d '{ "source_account_id": 3, "follower_account_id": 5, "lot_mode": "multiplier", "lot_value": 0.5, "reverse": false, "copy_existing": false, "filters": { "symbols": ["EURUSD", "XAUUSD"], "max_open_positions": 10 } }' ``` Other settings: `filters.min_lot` / `filters.max_lot` (bounds applied after scaling; below-minimum copies are skipped), `filters.max_open_positions`, `symbol_map` (e.g. `{"EURUSD": "EURUSD.r"}` for broker suffix differences). Slippage tolerance (a copy filling outside it is closed again) and SL/TP mirroring are configured in the dashboard; `/v1` does not accept them yet. `GET /v1/copiers/{id}/performance?range=3m` returns `copied_pnl` (attributed by follower ticket, so the follower's own manual trades never inflate it), `fidelity_pct`, latency percentiles, `slippage_by_symbol`, and `divergence` (both accounts normalised to percentage returns). Slippage is `null` for copies placed before fill-price capture existed. Positive slippage always means **worse for the follower**, in both directions. --- ### Billing — slots A **slot** is a paid seat; connecting an account occupies one of its tier. These endpoints let a platform provision and release slots programmatically. ``` GET /v1/usage Slot usage per tier (used / limit / available) GET /v1/slots List your slots (paginated; ?status= filter) GET /v1/slots/payment-method Is a card on file? Check before purchasing. POST /v1/slots Buy a slot — CHARGES the saved card DELETE /v1/slots/{id} Release a slot and cancel its subscription ``` **Purchasing requires a saved payment method.** Card collection needs a browser (PCI compliance and 3-D Secure), so there is deliberately no API endpoint to store a card — add one once in the dashboard under Billing, after which the API can charge it. ```bash # 1. Confirm a card is on file. curl https://api.metakit.cloud/v1/slots/payment-method \ -H "Authorization: Bearer $METAKIT_KEY" # { "configured": true, "brand": "visa", "last4": "4242" } # 2. Buy a slot (charges immediately, starts a subscription). curl -X POST https://api.metakit.cloud/v1/slots \ -H "Authorization: Bearer $METAKIT_KEY" \ -H "Content-Type: application/json" \ -d '{ "tier": "full" }' ``` ```json { "slot": { "id": "6a5a8ba4e7637702503bd8f1", "tier": "full", "status": "active", "connected_account_id": null, "created_at": "2026-07-28T10:00:00.000Z", "updated_at": "2026-07-28T10:00:00.000Z" }, "charged_to": { "brand": "visa", "last4": "4242" } } ``` Billing-specific errors: | Status | Code | Meaning | |--------|---------------------------|------------------------------------------------| | 402 | `payment_method_required` | No saved card — add one in the dashboard | | 409 | `slot_occupied` | Disconnect the account before releasing | | 409 | `already_canceled` | Slot was already released | Notes: - Slot `status` is `incomplete` when the charge has not settled (e.g. 3-D Secure). It becomes usable once the subscription is `active` — poll `GET /v1/slots` or wait rather than treating the purchase as failed. - Releasing a slot **never** disconnects an account. That ordering is deliberate: a billing call must not take a live trading account offline as a side effect. - `GET /v1/slots` includes canceled slots so you can reconcile your own records; filter with `?status=active` for live ones. ### Brokers and webhooks ``` GET /v1/brokers?q=IC Search brokers (paginated). Gives broker_id. GET /v1/webhooks List webhooks (includes each signing secret) POST /v1/webhooks Create { name, url } PATCH /v1/webhooks/{id} Update { name?, url? } POST /v1/webhooks/{id}/rotate Issue a new signing secret DELETE /v1/webhooks/{id} Delete ``` A webhook object: ```json { "id": "6712a1f9c3e4b5a7d8e9f012", "name": "Trade alerts", "url": "https://example.com/hooks/metakit", "secret": "whsec_4f1c…", "lastDeliveryStatus": "success", "lastDeliveryAt": "2026-07-28T09:41:02.000Z", "createdAt": "2026-07-10T12:00:00.000Z" } ``` `secret` is returned only to the webhook's owner. Store it wherever your receiver reads it from; it is what you verify signatures against. --- ## Webhooks Prefer webhooks over polling. Events POST as JSON to your URL. ### Signature verification (HMAC-SHA256) Every delivery is signed. Each webhook endpoint has its own secret (`whsec_` + 64 hex chars), shown in the dashboard under Settings → Webhooks and returned as `secret` from `GET /v1/webhooks`. Requests carry three headers of interest: ``` Content-Type: application/json X-MetaKit-Event: X-MetaKit-Signature: t=1769594460,v1=5f2c...9ab ``` `t` is the Unix timestamp (seconds) of the delivery; `v1` is the hex HMAC-SHA256 of `"."` keyed with the endpoint secret. **Verify against the raw request body, before any JSON parsing.** Re-serializing the parsed object produces different bytes and the signature will not match. ```javascript import crypto from 'node:crypto'; import express from 'express'; const app = express(); app.post( '/hooks/metakit', express.raw({ type: 'application/json' }), // raw Buffer, not express.json() (req, res) => { if (!verify(req.body, req.get('X-MetaKit-Signature'), process.env.METAKIT_WEBHOOK_SECRET)) { return res.sendStatus(400); } res.sendStatus(200); // ack fast; process asynchronously void handle(JSON.parse(req.body.toString('utf8'))); }, ); function verify(rawBody, header, secret, toleranceSec = 300) { if (!header) return false; const parts = Object.fromEntries( header.split(',').map((kv) => kv.split('=').map((s) => s.trim())), ); const { t, v1 } = parts; if (!t || !v1) return false; // Reject stale deliveries so a captured payload cannot be replayed later. if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false; const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex'); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(v1, 'utf8'); // Constant-time compare — a plain === leaks timing information. return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` Python equivalent: ```python import hmac, hashlib, time def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = dict(p.strip().split('=', 1) for p in header.split(',')) t, v1 = parts.get('t'), parts.get('v1') if not t or not v1: return False if abs(time.time() - int(t)) > tolerance: return False expected = hmac.new( secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, v1) ``` **Rotation.** `POST /v1/webhooks/{id}/rotate` issues a new secret and returns the updated webhook. The change is immediate: deliveries already in flight were signed with the old secret, so accept both for a short window while you deploy. Rejecting unsigned or badly signed requests is the whole point — do not skip verification because the payload "looks right". ### Three payload shapes The three event families have **different payload shapes**. There is no single envelope — branch on the family first. **1. Account events.** Wire names: `account.created`, `account.connected`, `account.error`, `account.invalid_credentials`, `account.deleted`. ```json { "event": "account.connected", "timestamp": "2026-07-28T09:41:00.000Z", "account": { "id": 2, "status": "connected", "balance": 406944.0, "...": "full account object" } } ``` The `account` field is the **complete account object** documented above — not an id. Live figures are populated when the account is running. **2. Trade events.** `X-MetaKit-Event` is prefixed with `trade.`, but the `event` field in the body is **not**. For a position open you receive the header `X-MetaKit-Event: trade.position.opened` and the body `"event": "position.opened"`. Match on one or the other consistently. Body `event` values: `position.opened`, `position.modified`, `position.closed`, `order.placed`, `order.filled`, `order.cancelled`, `order.expired`. ```json { "event": "position.opened", "timestamp": "2026-07-28T09:41:00.000Z", "account_id": 2, "data": { "ticket": 99302156, "symbol": "EURUSD", "type": "buy", "volume": 0.1 }, "changes": { "sl": { "from": 1.16, "to": 1.165 } } } ``` `data` is the full position/order snapshot. `changes` is present **only** on `position.modified` and carries `sl`, `tp`, and/or `volume` as `{from, to}`. `position.closed` additionally carries the realised outcome, read from the closing deal(s) — enough to grade a trade without polling `/deals`: ```json { "event": "position.closed", "timestamp": "2026-09-22T15:10:02.000Z", "account_id": 2, "data": { "ticket": 48812231, "symbol": "XAUUSD.m", "type": "buy", "volume": 0.12, "priceOpen": 2331.42, "sl": 2310.50, "tp": 2362.00, "profit": 366.96, "commission": -0.84, "swap": 0, "close_price": 2362.00, "close_time": "2026-09-22T15:10:01.000Z", "reason": "tp", "deal_ticket": 91004402 } } ``` `reason` is `sl`, `tp`, `stopout`, `manual` (closed by a client — including this API and copiers) or `other`. `profit` is the realised profit of the closing deal(s) excluding `commission` and `swap`, which are given separately. `order.expired` fires instead of `order.cancelled` when a time-limited pending order lapsed; `data` is the order as last seen (with its `expiration`). **3. Copier events.** Wire names already include the prefix and are identical in header and body: `copier.trade_copied`, `copier.trade_skipped`, `copier.orphan`, `copier.error`. ```json { "event": "copier.trade_copied", "timestamp": "2026-07-28T09:41:00.000Z", "copier_id": 4, "source_account_id": 2, "follower_account_id": 7, "action": "open", "message": "Copied EURUSD buy 0.10", "ok": true, "latency": { "a": 120, "b": 45, "c": 30, "total": 195 } } ``` `latency` (milliseconds) is present on successful copies only: `a` = detection, `b` = dispatch, `c` = execution, `total` = end to end. ### Delivery semantics - **Timestamp field is `timestamp`**, not `created_at`, on all three shapes. - **Retries:** up to 5 attempts with backoff 0.5s, 1s, 2s, 4s. A non-2xx or a timeout over **5 seconds** counts as failure. Ack immediately and process asynchronously, or you will be retried. - **No dedupe key.** Retries deliver a byte-identical payload with no delivery id, so make handlers idempotent on your side (for trades, `data.ticket`). - **Retries reuse the original signature and `t`.** The timestamp is stamped once per event, not per attempt, so a delivery that lands after several retries can be a few seconds older than "now" — keep your tolerance window at or above ~5 minutes rather than a couple of seconds. - **Fan-out:** every webhook you own receives every event. Filter by `event` and `account_id` yourself; there is no per-hook event subscription. - **Ordering is not guaranteed.** Independent retry schedules mean a `closed` can arrive before its `opened`. --- ## Integration recipes ### Connect an account and wait until it is ready ```javascript const headers = { Authorization: `Bearer ${process.env.METAKIT_KEY}`, 'Content-Type': 'application/json', }; const res = await fetch('https://api.metakit.cloud/v1/accounts', { method: 'POST', headers, body: JSON.stringify({ name: 'My Account', number: 40317, password: '...', broker_id: 210, server: 'ICMarketsSC-Demo', type: 'full', }), }); const account = await res.json(); // Connecting can take minutes on first connect. Prefer the // `account.connected` webhook; poll only if you must. let status = account.status; while (status === 'starting') { await new Promise((r) => setTimeout(r, 5000)); const check = await fetch( `https://api.metakit.cloud/v1/accounts/${account.id}`, { headers }, ); status = (await check.json()).status; } if (status !== 'connected') throw new Error(`Account failed: ${status}`); ``` ### Provision a slot, then connect an account ```javascript // Connecting fails with 409 no_slots_available if no free slot of that tier // exists. Check usage first and buy only what you need. const usage = await (await fetch('https://api.metakit.cloud/v1/usage', { headers })).json(); if (usage.full_accounts.available === 0) { const pm = await ( await fetch('https://api.metakit.cloud/v1/slots/payment-method', { headers }) ).json(); if (!pm.configured) { throw new Error('Add a payment method in the dashboard before buying slots.'); } const res = await fetch('https://api.metakit.cloud/v1/slots', { method: 'POST', headers, body: JSON.stringify({ tier: 'full' }), }); if (res.status === 402) throw new Error('Payment method required.'); const { slot } = await res.json(); // 'incomplete' means the charge has not settled yet (e.g. 3-D Secure). if (slot.status !== 'active') { throw new Error(`Slot pending (${slot.status}) — retry shortly.`); } } // A free slot now exists; POST /v1/accounts will succeed. ``` ### Release slots when a customer churns ```javascript const { data: slots } = await ( await fetch('https://api.metakit.cloud/v1/slots?status=active', { headers }) ).json(); for (const slot of slots.filter((s) => s.connected_account_id === null)) { await fetch(`https://api.metakit.cloud/v1/slots/${slot.id}`, { method: 'DELETE', headers, }); } // Occupied slots return 409 — disconnect the account first, deliberately. ``` ### Fetch a chart ```javascript // The account is part of the path — never a query parameter. const url = new URL('https://api.metakit.cloud/v1/accounts/2/candles'); url.searchParams.set('symbol', 'EURUSD'); url.searchParams.set('timeframe', 'H1'); url.searchParams.set('count', '500'); const { data } = await (await fetch(url, { headers })).json(); // data: [{ time, open, high, low, close, tickVolume, spread, realVolume }] ``` ### Backfill a tick range ```javascript // Walk day by day: a single wide window will hit the 50000 cap. for (let d = new Date('2026-07-01'); d < new Date('2026-07-08'); d.setDate(d.getDate() + 1)) { const from = new Date(d).toISOString(); const to = new Date(d.getTime() + 86400000).toISOString(); const u = new URL('https://api.metakit.cloud/v1/accounts/2/ticks'); u.searchParams.set('symbol', 'EURUSD'); u.searchParams.set('from', from); u.searchParams.set('to', to); const page = await (await fetch(u, { headers })).json(); if (page.truncated) console.warn(`${from}: capped — split this day further`); await store(page.data); } ``` --- ## Gotchas 1. **`status` gates everything.** Read endpoints return `409 account_not_running` / `502`, and trading endpoints `409 account_not_connected`, unless the account is `connected`. Always check first. 2. **Connecting is slow the first time** (~30s–several minutes). Do not treat a `starting` account as a failure. 3. **`readonly` accounts cannot trade** and cannot be a copier follower. 4. **`type` is fixed at creation** and consumes a slot of that tier. 5. **Deals are not trades.** One trade = two deals sharing a `position_id`. 6. **Use `timeline` for charts, `sessions` for statistics.** 7. **Deleting a copier leaves copied positions open.** Set state to `monitor` first if you want them wound down with the master. 8. **`realVolume` is usually 0 on FX.** Use `tickVolume` for activity. 9. **Ticks: `to` changes the query mode.** With `to`, `count` is ignored. 10. **Passwords are write-only.** They are never returned by any endpoint. 11. **Always send `Idempotency-Key` on trading writes.** A retry without one after a `504 terminal_timeout` can fill twice. 12. **Volumes are never rounded.** Round to `volume_step` yourself from the symbol spec; an off-step volume is `422 invalid_volume`. --- ## Rate limits and caching There is no hard published rate limit today, but be reasonable — each request may reach a real MT5 terminal. Guidance: - Deal history is cached ~60s per account server-side. Polling `/performance` faster than that returns the same data. - Use webhooks for events rather than polling positions in a tight loop. - For charts, request candles at the timeframe you display; do not fetch M1 and aggregate client-side. ## Support - Docs: https://app.metakit.cloud/docs - Email: support@metakit.cloud