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.
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.
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:
- A
maintenanceevent arrives, about 10 seconds before anything happens. ItsscheduledAtis the restart moment. - The stream closes at that moment. There's no close frame, so most clients report code 1006. Treat any close as "reconnect".
- 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. - 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 themaintenanceyou already saw. - 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
- Deduplicate by
id. The replay on each reconnect repeats events you've already seen. - Ignore anything past
expiresAt. It's stale by definition. - 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". - Pace reconnects inside the window. From
scheduledAtuntilscheduledAt + estimatedDurationSec, retry everyretryAfterSecseconds 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. - 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.