Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,18 @@ There are two authentication methods:

### Web login (default)

Web login is the newer method that uses the same login method as [app.traderepublic.com](https://app.traderepublic.com/),
meaning you receive a four-digit code in the TradeRepublic app or via SMS. This will keep you logged in your primary
device, but means that you may need to repeat entering a new four-digit code ever so often when runnnig `pytr`.
Web login uses the public web-login endpoints at `api.traderepublic.com`. Two variants are available:

- **v1 (default)**: `pytr login`. You receive a four-digit code in the TradeRepublic app (or via SMS as a
fallback) and enter it in the terminal. This is the original behavior; nothing changes for existing users.
- **v2 push approval (opt-in)**: `pytr login --v2`. Mirrors what
[app.traderepublic.com](https://app.traderepublic.com/) currently does: no numeric code, you approve the login from
a push notification in the Trade Republic mobile app. Useful if your account no longer issues a code via the app or
SMS. The `--v2` flag is available on every subcommand that performs a login, e.g. `pytr portfolio --v2`,
`pytr dl_docs --v2`.

Both variants keep you logged in on your primary device, but you may need to re-authenticate every so often when
running `pytr`.

### App login

Expand Down
49 changes: 30 additions & 19 deletions pytr/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def get_settings(tr):
return formatted_json


def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwright"):
def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwright", v2=False):
"""
Handle credentials parameters and store to credentials file if requested.
If no parameters are set but are needed then ask for input
Expand Down Expand Up @@ -52,7 +52,13 @@ def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwrigh
else:
save_cookies = False

tr = TradeRepublicApi(phone_no=phone_no, pin=pin, save_cookies=save_cookies, waf_token=waf_token)
tr = TradeRepublicApi(
phone_no=phone_no,
pin=pin,
save_cookies=save_cookies,
waf_token=waf_token,
use_v2_login=v2,
)

# Use same login as app.traderepublic.com
if not tr.resume_websession():
Expand All @@ -61,23 +67,28 @@ def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwrigh
except ValueError as e:
log.fatal(str(e))
sys.exit(1)
request_time = time.time()
print("Enter the code you received to your mobile app as a notification.")
print(f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})")
code = input("Code: ")
if code == "":
countdown = countdown - (time.time() - request_time)
for remaining in range(int(countdown)):
print(
f"Need to wait {int(countdown - remaining)} seconds before requesting SMS...",
end="\r",
)
time.sleep(1)
print()
tr.resend_weblogin()
code = input("SMS requested. Enter the confirmation code:")
tr.complete_weblogin(code)
log.info("Logged in.")
if v2:
# v2 push-approve flow: initiate_weblogin() already polled and got approval.
tr.complete_weblogin("")
log.info("Logged in.")
else:
request_time = time.time()
print("Enter the code you received to your mobile app as a notification.")
print(f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})")
code = input("Code: ")
if code == "":
countdown = countdown - (time.time() - request_time)
for remaining in range(int(countdown)):
print(
f"Need to wait {int(countdown - remaining)} seconds before requesting SMS...",
end="\r",
)
time.sleep(1)
print()
tr.resend_weblogin()
code = input("SMS requested. Enter the confirmation code:")
tr.complete_weblogin(code)
log.info("Logged in.")

log.debug(get_settings(tr))
return tr
131 changes: 126 additions & 5 deletions pytr/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
# SOFTWARE.

import asyncio
import base64
import json
import os
import pathlib
import re
import ssl
Expand All @@ -45,6 +47,19 @@
BASE_DIR = home / ".pytr"
CREDENTIALS_FILE = BASE_DIR / "credentials"
COOKIES_FILE = BASE_DIR / "cookies.txt"
DEVICE_ID_FILE = BASE_DIR / "device_id"

# v2 web-login values captured from app.traderepublic.com on 2026-05-29.
# Only used when the caller opts into the v2 push-approval flow.
# Overridable via env vars in case Trade Republic bumps them.
TR_WEB_APP_VERSION = os.environ.get("PYTR_TR_APP_VERSION", "15.7.0")
TR_WEB_USER_AGENT_V2 = os.environ.get(
"PYTR_TR_USER_AGENT",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
)
TR_V1_LOGIN_PATH = "/api/v1/auth/web/login"
TR_V2_LOGIN_PATH = "/api/v2/auth/web/login"


class TradeRepublicApi:
Expand Down Expand Up @@ -75,11 +90,13 @@ def __init__(
credentials_file=None,
cookies_file=None,
waf_token="playwright",
use_v2_login=False,
):
self.log = get_logger(__name__)
self._locale = locale
self._save_cookies = save_cookies
self._waf_token = waf_token
self._use_v2_login = use_v2_login

self._credentials_file = pathlib.Path(credentials_file) if credentials_file else CREDENTIALS_FILE

Expand Down Expand Up @@ -183,6 +200,49 @@ def _fetch_waf_token_playwright(self, timeout_ms: int = 30000):
self.log.warning("AWS WAF token not acquired. Value is None.")
return token

def _get_device_id(self) -> str:
"""Return a stable per-install device UUID, creating it on first use."""
try:
return DEVICE_ID_FILE.read_text().strip()
except FileNotFoundError:
BASE_DIR.mkdir(parents=True, exist_ok=True)
device_id = uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars, matches web app
DEVICE_ID_FILE.write_text(device_id)
return device_id

def _build_device_info_header(self) -> str:
"""Build the base64(JSON) x-tr-device-info payload the web app sends."""
payload = {
"stableDeviceId": self._get_device_id(),
"model": "Apple Macintosh",
"browser": "Chrome",
"browserVersion": "148.0.0.0",
"os": "Mac OS",
"osVersion": "10.15.7",
"timezone": "Europe/Amsterdam",
"timezoneOffset": -120,
"screen": "1800x1169x30",
"preferredLanguages": ["en", "en-US"],
"numberOfCores": 12,
"deviceMemory": 16,
}
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return base64.b64encode(raw).decode("ascii")

def _auth_headers(self) -> Dict[str, str]:
"""Headers required by /api/v2/auth/web/login (v2 push-approval flow)."""
headers = {
"User-Agent": TR_WEB_USER_AGENT_V2,
"Origin": "https://app.traderepublic.com",
"Referer": "https://app.traderepublic.com/",
"x-tr-platform": "web",
"x-tr-app-version": TR_WEB_APP_VERSION,
"x-tr-device-info": self._build_device_info_header(),
}
if self._waf_token:
headers["x-aws-waf-token"] = self._waf_token
return headers

def _set_waf_cookie(self, token: str):
"""Set the aws-waf-token cookie on the web session."""
cookie = Cookie(
Expand Down Expand Up @@ -221,9 +281,13 @@ def initiate_weblogin(self):
else:
self.log.warning("No WAF token available.")

login_path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH
extra_headers = self._auth_headers() if self._use_v2_login else None

r = self._websession.post(
f"{self._host}/api/v1/auth/web/login",
f"{self._host}{login_path}",
json={"phoneNumber": self.phone_no, "pin": self.pin},
headers=extra_headers,
)
self.log.debug(f"Web login returned: {r.status_code}")
r.raise_for_status()
Expand All @@ -237,20 +301,77 @@ def initiate_weblogin(self):
raise ValueError(str(err))
else:
raise ValueError("processId not in reponse")
return int(j["countdownInSeconds"]) + 1

if not self._use_v2_login:
return int(j["countdownInSeconds"]) + 1

# v2 web login uses push-to-approve in the TR mobile app: no SMS/code.
# Poll the process endpoint until the push is approved (or rejected/expired).
self.await_web_login_approval()
return 0

def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0):
"""Block until the TR mobile-app push for this login process is approved."""
if not self._process_id:
raise ValueError("Initiate web login first.")
url = f"{self._host}{TR_V2_LOGIN_PATH}/processes/{self._process_id}"
deadline = time.time() + timeout_s
print(
f"Approve the login in your Trade Republic mobile app (waiting up to {timeout_s}s)...",
flush=True,
)
while time.time() < deadline:
r = self._websession.get(url, headers=self._auth_headers())
if r.status_code == 200:
try:
j = r.json()
except ValueError:
j = {}
state = str(j.get("state") or j.get("status") or "").upper()
if state in ("APPROVED", "COMPLETED", "SUCCESS", "OK", "DONE"):
self.save_websession()
self.log.info("Push approved.")
return
if state in ("REJECTED", "DECLINED", "FAILED", "EXPIRED"):
raise ValueError(f"Login process {state.lower()}: {j}")
# State unknown but session cookie present? Treat as approved.
for c in self._websession.cookies:
if c.name in ("tr_session",) and c.value:
self.save_websession()
self.log.info("Session cookie detected, login complete.")
return
elif r.status_code in (401, 403, 404, 410):
raise ValueError(f"Login process rejected or expired ({r.status_code}): {r.text[:200]}")
else:
self.log.warning("Login poll %s: %s", r.status_code, r.text[:200])
time.sleep(interval_s)
raise TimeoutError("Push approval not received within timeout.")

def resend_weblogin(self):
path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH
headers = self._auth_headers() if self._use_v2_login else self._default_headers
r = self._websession.post(
f"{self._host}/api/v1/auth/web/login/{self._process_id}/resend",
headers=self._default_headers,
f"{self._host}{path}/{self._process_id}/resend",
headers=headers,
)
r.raise_for_status()

def complete_weblogin(self, verify_code):
if not self._process_id and not self._websession:
raise ValueError("Initiate web login first.")

r = self._websession.post(f"{self._host}/api/v1/auth/web/login/{self._process_id}/{verify_code}")
# v2 push flow: approval already happened during initiate_weblogin().
# An empty verify_code signals nothing left to do.
if self._use_v2_login and not verify_code:
self.save_websession()
return

path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH
headers = self._auth_headers() if self._use_v2_login else None
r = self._websession.post(
f"{self._host}{path}/{self._process_id}/{verify_code}",
headers=headers,
)
r.raise_for_status()
self.save_websession()

Expand Down
17 changes: 17 additions & 0 deletions pytr/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ def formatter(prog):
action="store_true",
default=False,
)
parser_login_args.add_argument(
"--v2",
help=(
"Use the v2 web-login flow (push approval via the Trade Republic "
"mobile app). Default is the legacy v1 flow with an SMS/notification code."
),
action="store_true",
default=False,
)

# parent subparser for lang option
parser_lang = argparse.ArgumentParser(add_help=False)
Expand Down Expand Up @@ -463,6 +472,7 @@ def main():
pin=args.pin,
store_credentials=args.store_credentials,
waf_token=args.waf_token,
v2=args.v2,
)
elif args.command == "portfolio":
Portfolio(
Expand All @@ -471,6 +481,7 @@ def main():
pin=args.pin,
store_credentials=args.store_credentials,
waf_token=args.waf_token,
v2=args.v2,
),
args.include_watchlist,
lang=args.lang,
Expand All @@ -486,6 +497,7 @@ def main():
pin=args.pin,
store_credentials=args.store_credentials,
waf_token=args.waf_token,
v2=args.v2,
),
args.isin,
).get()
Expand All @@ -496,6 +508,7 @@ def main():
pin=args.pin,
store_credentials=args.store_credentials,
waf_token=args.waf_token,
v2=args.v2,
),
args.output,
args.format,
Expand Down Expand Up @@ -525,6 +538,7 @@ def main():
pin=args.pin,
store_credentials=args.store_credentials,
waf_token=args.waf_token,
v2=args.v2,
),
args.outputdir,
not_before,
Expand Down Expand Up @@ -559,6 +573,7 @@ def main():
pin=args.pin,
store_credentials=args.store_credentials,
waf_token=args.waf_token,
v2=args.v2,
),
args.input,
args.outputfile,
Expand All @@ -574,6 +589,7 @@ def main():
pin=args.pin,
store_credentials=args.store_credentials,
waf_token=args.waf_token,
v2=args.v2,
),
args.input,
args.inputfile,
Expand All @@ -589,6 +605,7 @@ def main():
pin=args.pin,
store_credentials=args.store_credentials,
waf_token=args.waf_token,
v2=args.v2,
),
args.outputfile,
decimal_localization=args.decimal_localization,
Expand Down