Changing a terminal's password
Runs against the dev environment today, where every path below was run on 2 October 2026, and a change on a terminal with open positions on 3 October 2026. The sample it quotes is [
../samples/change_password.py].
You changed your password at the broker, or the one you submitted was wrong and the terminal is
FAILED. Either way the fix is the same call you used the first time: submit the credential
again. You do not retire the terminal, and it keeps its id, its instruments and its history.
Submit it again
From the sample:
return await client.terminals.submit_credentials(
terminal_id,
login=login,
password=password,
server=server,
idempotency_key=f"change-password-{uuid.uuid4()}",
)
takes_effect = field(answer, "takes_effect", "first_login")
The first submission for a terminal stores its credential. Every later one for the same login replaces the password. The answer tells you when the terminal will use it:
takes_effect |
The terminal was | What happens |
|---|---|---|
restart |
ACTIVE |
It is restarted now and logs in with the new password. Expect a short gap in ticks |
retry_login |
FAILED |
It rejoins the login queue now. This is how you recover from a wrong password |
next_login |
anything else | Nothing is started. Its next login uses the new password |
first_login |
new, with no credential yet | The ordinary first submission |
operation_id names the restart or retry so you can follow it. It is empty for next_login.
Open positions do not hold a password change back. An ACTIVE terminal is restarted even when
the account has open positions, because a restart is the only way the new password is used. Your
positions stay open at the broker while the terminal reconnects; expect a gap of a few seconds in
ticks and account updates.
You can still get next_login for an ACTIVE or FAILED terminal. That means the restart or retry
could not start right now, and not_applied_reason says why, using the same code the restart call
would have answered:
not_applied_reason |
Means |
|---|---|
operation_in_progress |
Another restart, rebuild or move is already running on this terminal |
cooldown |
The terminal was restarted moments ago |
tenant_rate or concurrency |
You have used your restarts for now |
positions_open |
Your platform operator has switched on the confirmation for restarts with open positions |
The new password is stored all the same. Restart the terminal when you are ready, or leave it: its
next login uses the new password. not_applied_reason is empty in every other answer.
What is refused
- A different login. The call answers 409
conflictwithdetails.reasonset tologin_changed. Another login is another account, with its own positions and history, so it needs its own terminal: request a new one, and retire this one when you are done with it. - A terminal that is going.
DRAININGandRETIREDanswer 409state_error. - A different server. The credential’s server must be the terminal’s own; anything else is a 400.
When the broker says no
If the broker refuses the login, the terminal becomes FAILED within a couple of minutes and its
BrokerConnected condition reads failed with the reason login_refused. MFX Hub does not keep
retrying a refused login, because repeated bad logins are how a broker locks an account. Nothing
happens until you act:
- Check the login, the password and the server at your broker.
- Submit the credential again. The answer is
retry_loginand the terminal logs in.
A login that fails for another reason, such as a broker that did not answer in time, is different: MFX Hub tries it again by itself after 30 minutes, up to three times in a day. You can still submit the credential again, or ask for a retry, without waiting.
A broker gives the same answer for a wrong password as for an account that no longer exists, so MFX Hub only ever tells you that the login was refused. Demo accounts expire; if the password is right and the login is still refused, check that the account is still open.
An investor password
Submitting an investor (read-only) password turns order execution off for the terminal, and the
answer says so in execution_disabled. Submitting a master password later does not turn it back on
by itself.
More than one terminal on the login
Each terminal holds its own copy of the credential, so a password you changed at the broker has to
be submitted to every terminal that uses the login. The answer counts what is left:
same_login_terminals_stale is how many of your other terminals on the login were last given
their password before this one. Submit to each until it reads 0. See
Several terminals on one broker login.
Your password is never returned
The password travels once, over TLS, into the platform’s vault. No API returns it, no log holds it, and the answer to this call is a reference and nothing else.
Run it
python samples/change_password.py <terminal id> --key-id mfxk_... --private-key-file <your key file>
Your key needs the scopes term:read and term:write. The login, the server and the new password
are typed at a prompt and go nowhere else. The script prints each change of state and ends with
PASS when the terminal is ACTIVE on the new password.
The runnable sample: samples/change_password.py
Executed in CI against MFX Hub on every change to the knowledge base.
"""Change a terminal's broker password: submit the new one, and watch the terminal log in with it.
The runnable half of guides/changing-a-terminals-password.md. It does only what a customer can do:
every call is the public, signed API through the SDK.
python samples/change_password.py <terminal id> --key-id mfxk_... --private-key-file <your key file>
The login, the server and the new password are typed at a prompt. They are never an argument, an
environment variable or a file, so they reach no shell history, no process list and no CI log.
"""
from __future__ import annotations
import argparse
import asyncio
import getpass
import time
import uuid
from pathlib import Path
from typing import Any
from mfx import MfxApiError, MfxClient, Secret, SigningKey
BASE_URL = "https://api.dev.mfxhub.com/v1"
POLL_S = 5
#: A restart or a fresh login is usually under two minutes; a cold host can take longer.
TIMEOUT_S = 20 * 60
#: How long a restart has to show itself before a live terminal is taken as already restarted.
RESTART_SEEN_S = 120
#: What shows a terminal is logged in. Ticks are not part of it: a terminal with no instruments,
#: or one whose market is closed, is logged in and quotes nothing.
LIVE = ("ProcessRunning", "BrokerConnected")
def field(answer: Any, name: str, default: Any = None) -> Any:
"""A field of the answer, whether or not this SDK version has a typed attribute for it."""
data = answer.to_dict() if hasattr(answer, "to_dict") else dict(answer)
return data.get(name, default)
async def change_password(
client: MfxClient, terminal_id: str, *, login: int, server: str, password: Secret
) -> Any:
"""Submit the same login with its new password. The second submission is the rotation."""
try:
return await client.terminals.submit_credentials(
terminal_id,
login=login,
password=password,
server=server,
idempotency_key=f"change-password-{uuid.uuid4()}",
)
except MfxApiError as refused:
# 409 conflict: another login is another account, so it needs its own terminal.
# 409 state_error: the terminal is draining or retired.
raise SystemExit(f"REFUSED: {refused}") from refused
def live(terminal: Any) -> bool:
"""ACTIVE with the process up and the broker connected."""
if str(terminal.state) != "ACTIVE":
return False
seen = terminal.conditions.to_dict() if terminal.conditions else {}
return all(seen.get(name, {}).get("status") == "ok" for name in LIVE)
async def wait_until_live(client: MfxClient, terminal_id: str, *, restarting: bool) -> str:
"""Poll until the terminal is live on the new password, printing each change.
A restart keeps the terminal `ACTIVE` while its host stops and starts it, so the state alone
cannot show it finished. `restarting` waits to see it stop being live first, for up to
`RESTART_SEEN_S`; a restart quicker than the poll is taken as done.
"""
started = time.monotonic()
last = None
seen_down = not restarting
while time.monotonic() - started < TIMEOUT_S:
terminal = await client.terminals.get(terminal_id)
now_live = live(terminal)
shown = (str(terminal.state), now_live)
if shown != last:
print(f"[{int(time.monotonic() - started):>4}s] {shown[0]} live={now_live}")
last = shown
if str(terminal.state) in ("FAILED", "RETIRED"):
return str(terminal.state)
seen_down = seen_down or not now_live or time.monotonic() - started > RESTART_SEEN_S
if now_live and seen_down:
return "ACTIVE"
await asyncio.sleep(POLL_S)
raise SystemExit(f"FAILED: not live after {TIMEOUT_S}s")
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("terminal_id")
parser.add_argument("--key-id", required=True)
parser.add_argument("--private-key-file", required=True, type=Path)
parser.add_argument("--base-url", default=BASE_URL)
args = parser.parse_args()
client = MfxClient(
args.base_url, args.key_id, SigningKey(args.private_key_file.read_text().strip())
)
before = await client.terminals.get(args.terminal_id)
print(f"terminal {args.terminal_id} is {before.state}")
login = int(input("Broker login (the one this terminal already has): "))
server = input("Broker server: ").strip()
password = Secret(getpass.getpass("New password: "))
answer = await change_password(
client, args.terminal_id, login=login, server=server, password=password
)
takes_effect = field(answer, "takes_effect", "first_login")
print(f"accepted: rotated={field(answer, 'rotated', False)} takes_effect={takes_effect}")
if takes_effect == "next_login":
why = field(answer, "not_applied_reason")
print("Stored. The terminal uses the new password the next time it logs in.")
if why:
print(f"It was not restarted now: {why}")
return
state = await wait_until_live(client, args.terminal_id, restarting=takes_effect == "restart")
print("PASS" if state == "ACTIVE" else f"FAILED: the terminal is {state}; read its conditions")
if __name__ == "__main__":
asyncio.run(main())