TICKERALL!
← All posts
October 29, 20265 min read

Surviving Planned Restarts: Maintenance Events on the MetaTrader 5 WebSocket

TickerAll announces every release on the stream before it happens. How to parse the maintenance and all-clear events, pace your reconnects, re-subscribe, and keep orders safe across a restart. TypeScript and Python examples.

MetaTrader 5WebSocketReliabilityTypeScriptPython

A trading bot that runs for weeks will live through our releases. When we ship an update, the API restarts: your WebSocket stream drops for a few seconds and your accounts reconnect to their brokers. Nothing is lost, but a bot that treats every drop as an emergency will page you, hammer the API with reconnects, or retry an order that already went through.

So every release now announces itself on the stream. About ten seconds before the restart you receive a maintenance event with the exact time and a suggested reconnect pace; when the API is back you receive a maintenance_clear that points back at it. This post shows what a restart looks like from your side, what the two events contain, and how to handle them in TypeScript and Python.

maintenance T − 10 s stream closes T (scheduledAt) retry every retryAfterSec reconnected replay → all-clear re-subscribed retry on NOT_HOT accounts back "active" In our test: event 10 s ahead, close at the announced second, back 11 s later.
A release from the client's side. Only the first step needs the event; the rest is a good reconnect loop.

What a restart looks like

We ran this end to end on our staging environment before writing it: a client connected the same way yours does, subscribed to an account, and we restarted the API underneath it. This is what the client saw:

  1. A maintenance event arrives, about 10 seconds before anything happens. Its scheduledAt is the restart moment.
  2. The stream closes at that moment. There's no close frame, so most clients report code 1006. Treat any close as "reconnect".
  3. Reconnect attempts fail briefly. While the API restarts, the WebSocket upgrade and REST calls answer 502 with a plain-text body (not JSON) and no Retry-After. Count those as normal failed attempts.
  4. The stream comes back. In our test, 11 seconds after the close. On every new connection the server replays the active events, newest first, so the first thing you read is the maintenance_clear, then the maintenance you already saw.
  5. Your accounts reconnect to their brokers one by one. Right after the restart, a subscribe for an account that isn't back yet is rejected with NOT_HOT. Retry it until it's accepted.

The two events

Service events arrive as {"type": "event", "event": {...}} on the same stream as your ticks and positions. You don't subscribe to them: every connection gets them. Here's the start of a release, exactly as sent (ids and times will differ):

{
  "type": "event",
  "event": {
    "id": "evt_c5e41311f3504c17acc6bde3975d600d",
    "kind": "maintenance",
    "severity": "warning",
    "title": "Maintenance starting — sessions may briefly drop",
    "createdAt": "2026-10-29T05:54:07.096Z",
    "body": "We are applying updates. Your broker sessions may drop and will reconnect automatically; an all-clear follows when it is done.",
    "url": "https://tickerall.com/status",
    "scheduledAt": "2026-10-29T05:54:17.096Z",
    "estimatedDurationSec": 60,
    "reason": "scheduled release",
    "expiresAt": "2026-10-29T05:59:07.096Z",
    "data": { "retryAfterSec": 10 }
  }
}

And the all-clear, sent as soon as the API answers again:

{
  "type": "event",
  "event": {
    "id": "evt_793103d7d38743619a363c046f490949",
    "kind": "maintenance_clear",
    "severity": "info",
    "title": "Maintenance complete — service restored",
    "createdAt": "2026-10-29T05:54:23.421Z",
    "body": "Updates are live and the service is back. Accounts are reconnecting now: each one shows alwaysHotState \"active\" in GET /v1/accounts once it is back; reconnect any that stays offline with POST /v1/sessions.",
    "url": "https://tickerall.com/status",
    "relatesTo": "evt_c5e41311f3504c17acc6bde3975d600d",
    "expiresAt": "2026-10-29T06:09:23.421Z"
  }
}
Field Meaning
id, kind, severity, title, createdAt Always present.
scheduledAt When the restart begins.
estimatedDurationSec Our estimate of the outage. An estimate, not a timer.
data.retryAfterSec How often to retry a dropped stream during the announced window.
relatesTo On an all-clear: the id of the maintenance event it closes.
expiresAt After this, the event is no longer replayed.

Optional fields are omitted, never null. Other kinds exist too (news, changelog, announcement); ignore any kind you don't handle.

Five rules for handling them

  1. Deduplicate by id. The replay on each reconnect repeats events you've already seen.
  2. Ignore anything past expiresAt. It's stale by definition.
  3. Pair the all-clear with its start through relatesTo. The replay can contain an older all-clear too, so don't treat any all-clear as "the one I'm waiting for".
  4. Pace reconnects inside the window. From scheduledAt until scheduledAt + estimatedDurationSec, retry every retryAfterSec seconds plus a little random jitter. Outside that window (or for a drop nobody announced), use normal exponential backoff: 1, 2, 4… seconds, capped at 30, with jitter. Don't sleep the whole estimate: the API is often back much sooner.
  5. Use the window to quiet your alerts. A drop inside an announced window is expected. Escalate only if you're still disconnected well after scheduledAt + estimatedDurationSec.

After you reconnect

Re-send your subscribe frames. Subscriptions belong to a connection, so a new connection starts empty. If a channel comes back rejected with NOT_HOT, that account hasn't reconnected to its broker yet: retry the subscribe every few seconds until it's accepted.

Check your accounts with GET /v1/accounts. Each account carries an alwaysHotState:

  • active — connected; nothing to do.
  • needs-rearm — it should be connected, but isn't yet. Always-hot accounts with remembered credentials come back on their own within a short while.
  • off — not always-hot. After a restart it stays offline until you reconnect it.

To bring an account back yourself, call POST /v1/accounts/{id}/reconnect (no password needed when its credentials are remembered) or POST /v1/sessions with the login. Both return the same account id.

Be careful with orders around the switch. An order that failed or timed out right at the restart may still have executed. Idempotency keys don't survive the restart, so a blind retry with the same key could place it twice. Check GET /v1/accounts/{id}/positions (and pending orders) first, for example by matching the order comment you set, and only then retry.

Keep a quiet stream alive. If you subscribe to nothing busy, send {"type": "ping"} every 30 seconds. An idle connection can otherwise be closed by a proxy along the way, and you'd reconnect for no reason.

TypeScript example

Node with the ws package. It applies all of the above: event handling, paced reconnects, re-subscribe with NOT_HOT retries, and a keepalive ping.

import WebSocket from "ws";

const API = "https://api.tickerall.com";
const API_KEY = process.env.TICKERALL_API_KEY!;
const ACCOUNT_ID = process.env.TICKERALL_ACCOUNT_ID!;

type SystemEvent = {
  id: string; kind: string; severity: string; title: string; createdAt: string;
  scheduledAt?: string; estimatedDurationSec?: number; relatesTo?: string;
  expiresAt?: string; data?: { retryAfterSec?: number };
};

const seen = new Set<string>();
let pendingStart: string | null = null; // id of the maintenance we're waiting on
let windowEnd = 0;                      // ms: end of the announced window
let retryAfterMs = 0;                   // suggested pace inside it
let attempt = 0;

function onSystemEvent(e: SystemEvent) {
  if (seen.has(e.id)) return;
  seen.add(e.id);
  if (e.expiresAt && Date.parse(e.expiresAt) <= Date.now()) return;

  if (e.kind === "maintenance") {
    const start = Date.parse(e.scheduledAt ?? e.createdAt);
    windowEnd = start + (e.estimatedDurationSec ?? 60) * 1000;
    retryAfterMs = (e.data?.retryAfterSec ?? 10) * 1000;
    pendingStart = e.id;
    console.log(`Maintenance at ${e.scheduledAt}: pausing new orders, muting alerts`);
  } else if (e.kind === "maintenance_clear" && e.relatesTo === pendingStart) {
    pendingStart = null;
    windowEnd = 0;
    console.log("All clear: resuming");
    void checkAccounts();
  }
}

function nextDelay(): number {
  const jitter = Math.random() * 1000;
  if (Date.now() < windowEnd && retryAfterMs > 0) return retryAfterMs + jitter;
  return Math.min(30_000, 1000 * 2 ** attempt++) + jitter;
}

function subscribe(ws: WebSocket) {
  ws.send(JSON.stringify({
    type: "subscribe",
    channels: [
      { kind: "account", accountId: ACCOUNT_ID },
      { kind: "positions", accountId: ACCOUNT_ID },
    ],
  }));
}

async function checkAccounts() {
  const res = await fetch(`${API}/v1/accounts`, { headers: { Authorization: `Bearer ${API_KEY}` } });
  if (!res.ok) return; // e.g. a 502 while restarting: try again later
  for (const a of await res.json()) {
    if (a.alwaysHotState !== "active") console.log(`${a.id} is ${a.alwaysHotState}`);
  }
}

function connect() {
  const ws = new WebSocket("wss://api.tickerall.com/v1/stream", {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  let retried = false;
  const retry = () => {
    if (retried) return;
    retried = true;
    clearInterval(keepalive);
    setTimeout(connect, nextDelay());
  };
  const keepalive = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
  }, 30_000);

  ws.on("open", () => { attempt = 0; subscribe(ws); });
  ws.on("message", (raw) => {
    const msg = JSON.parse(raw.toString());
    if (msg.type === "event") return onSystemEvent(msg.event);
    if (msg.type === "subscribed" && msg.rejected?.some((r: { code: string }) => r.code === "NOT_HOT")) {
      setTimeout(() => ws.readyState === WebSocket.OPEN && subscribe(ws), 5_000);
      return;
    }
    // ...ticks, positions and account updates go to your bot here
  });
  // A 502 during the restart arrives as a failed upgrade, not a close.
  ws.on("unexpected-response", (req, res) => { res.resume(); req.destroy(); retry(); });
  ws.on("close", retry);
  ws.on("error", () => { /* 'close' or 'unexpected-response' follows */ });
}

connect();

Python example

The same loop with websockets and asyncio:

import asyncio, json, os, random, time
from datetime import datetime
from urllib.parse import quote

import websockets

API_KEY = os.environ["TICKERALL_API_KEY"]
ACCOUNT_ID = os.environ["TICKERALL_ACCOUNT_ID"]
URL = f"wss://api.tickerall.com/v1/stream?token={quote(API_KEY)}"

seen: set[str] = set()
pending_start: str | None = None
window_end = 0.0    # epoch seconds: end of the announced window
retry_after = 0.0   # suggested pace inside it


def ts(iso: str) -> float:
    return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()


def on_system_event(e: dict) -> None:
    global pending_start, window_end, retry_after
    if e["id"] in seen:
        return
    seen.add(e["id"])
    if e.get("expiresAt") and ts(e["expiresAt"]) <= time.time():
        return
    if e["kind"] == "maintenance":
        start = ts(e.get("scheduledAt") or e["createdAt"])
        window_end = start + e.get("estimatedDurationSec", 60)
        retry_after = (e.get("data") or {}).get("retryAfterSec", 10)
        pending_start = e["id"]
        print(f"Maintenance at {e.get('scheduledAt')}: pausing new orders")
    elif e["kind"] == "maintenance_clear" and e.get("relatesTo") == pending_start:
        pending_start, window_end = None, 0.0
        print("All clear: resuming")


async def subscribe(ws) -> None:
    await ws.send(json.dumps({
        "type": "subscribe",
        "channels": [
            {"kind": "account", "accountId": ACCOUNT_ID},
            {"kind": "positions", "accountId": ACCOUNT_ID},
        ],
    }))


async def resubscribe_later(ws, delay: float = 5.0) -> None:
    await asyncio.sleep(delay)
    try:
        await subscribe(ws)
    except websockets.exceptions.ConnectionClosed:
        pass


async def keepalive(ws) -> None:
    while True:
        await asyncio.sleep(30)
        await ws.send(json.dumps({"type": "ping"}))


async def run() -> None:
    attempt = 0
    while True:
        try:
            async with websockets.connect(URL) as ws:
                attempt = 0
                await subscribe(ws)
                pinger = asyncio.create_task(keepalive(ws))
                try:
                    async for raw in ws:
                        msg = json.loads(raw)
                        if msg.get("type") == "event":
                            on_system_event(msg["event"])
                        elif msg.get("type") == "subscribed" and any(
                            r.get("code") == "NOT_HOT" for r in msg.get("rejected", [])
                        ):
                            asyncio.create_task(resubscribe_later(ws))
                        # ...ticks, positions and account updates go to your bot here
                finally:
                    pinger.cancel()
        except (websockets.exceptions.WebSocketException, OSError):
            pass  # closes, failed upgrades (502 while restarting), network errors
        jitter = random.random()
        if time.time() < window_end and retry_after:
            delay = retry_after + jitter
        else:
            delay = min(30, 2 ** attempt) + jitter
            attempt += 1
        await asyncio.sleep(delay)


asyncio.run(run())

Wrapping up

A release is now a predictable, ten-second-warned event instead of a surprise. Listen for maintenance, reconnect at the suggested pace, pair the all-clear by relatesTo, re-subscribe until nothing comes back NOT_HOT, and check positions before retrying any order that was in flight. The same loop also handles drops nobody announced, so it's worth having even if you never read the event text.

The basics of the stream (auth, subscribe frames, every channel) are in Streaming MetaTrader 5 over a Raw WebSocket, and the full API reference is at tickerall.com/docs.

Was this useful?

Comments

Leave a comment

Comments are public.