4949COOKIES_FILE = BASE_DIR / "cookies.txt"
5050DEVICE_ID_FILE = BASE_DIR / "device_id"
5151
52- # Web app values captured from app.traderepublic.com on 2026-05-29.
53- # These can be overridden via environment variables if Trade Republic bumps them.
52+ # v2 web-login values captured from app.traderepublic.com on 2026-05-29.
53+ # Only used when the caller opts into the v2 push-approval flow.
54+ # Overridable via env vars in case Trade Republic bumps them.
5455TR_WEB_APP_VERSION = os .environ .get ("PYTR_TR_APP_VERSION" , "15.7.0" )
55- TR_WEB_USER_AGENT = os .environ .get (
56+ TR_WEB_USER_AGENT_V2 = os .environ .get (
5657 "PYTR_TR_USER_AGENT" ,
5758 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
5859 "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" ,
5960)
60- TR_WEB_LOGIN_PATH = "/api/v2/auth/web/login"
61+ TR_V1_LOGIN_PATH = "/api/v1/auth/web/login"
62+ TR_V2_LOGIN_PATH = "/api/v2/auth/web/login"
6163
6264
6365class TradeRepublicApi :
6466 _default_headers = {
65- "User-Agent" : TR_WEB_USER_AGENT ,
66- "Origin" : "https://app.traderepublic.com" ,
67- "Referer" : "https://app.traderepublic.com/" ,
67+ "User-Agent" : "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
6868 }
6969 _host = "https://api.traderepublic.com"
7070 _waf_login_url = "https://app.traderepublic.com/login"
@@ -90,11 +90,13 @@ def __init__(
9090 credentials_file = None ,
9191 cookies_file = None ,
9292 waf_token = "playwright" ,
93+ use_v2_login = False ,
9394 ):
9495 self .log = get_logger (__name__ )
9596 self ._locale = locale
9697 self ._save_cookies = save_cookies
9798 self ._waf_token = waf_token
99+ self ._use_v2_login = use_v2_login
98100
99101 self ._credentials_file = pathlib .Path (credentials_file ) if credentials_file else CREDENTIALS_FILE
100102
@@ -228,8 +230,11 @@ def _build_device_info_header(self) -> str:
228230 return base64 .b64encode (raw ).decode ("ascii" )
229231
230232 def _auth_headers (self ) -> Dict [str , str ]:
231- """Headers required by /api/v2/auth/web/login."""
233+ """Headers required by /api/v2/auth/web/login (v2 push-approval flow) ."""
232234 headers = {
235+ "User-Agent" : TR_WEB_USER_AGENT_V2 ,
236+ "Origin" : "https://app.traderepublic.com" ,
237+ "Referer" : "https://app.traderepublic.com/" ,
233238 "x-tr-platform" : "web" ,
234239 "x-tr-app-version" : TR_WEB_APP_VERSION ,
235240 "x-tr-device-info" : self ._build_device_info_header (),
@@ -276,10 +281,13 @@ def initiate_weblogin(self):
276281 else :
277282 self .log .warning ("No WAF token available." )
278283
284+ login_path = TR_V2_LOGIN_PATH if self ._use_v2_login else TR_V1_LOGIN_PATH
285+ extra_headers = self ._auth_headers () if self ._use_v2_login else None
286+
279287 r = self ._websession .post (
280- f"{ self ._host } { TR_WEB_LOGIN_PATH } " ,
288+ f"{ self ._host } { login_path } " ,
281289 json = {"phoneNumber" : self .phone_no , "pin" : self .pin },
282- headers = self . _auth_headers () ,
290+ headers = extra_headers ,
283291 )
284292 self .log .debug (f"Web login returned: { r .status_code } " )
285293 r .raise_for_status ()
@@ -293,7 +301,9 @@ def initiate_weblogin(self):
293301 raise ValueError (str (err ))
294302 else :
295303 raise ValueError ("processId not in reponse" )
296- self .log .info ("Web login keys: %s" , sorted (j .keys ()))
304+
305+ if not self ._use_v2_login :
306+ return int (j ["countdownInSeconds" ]) + 1
297307
298308 # v2 web login uses push-to-approve in the TR mobile app: no SMS/code.
299309 # Poll the process endpoint until the push is approved (or rejected/expired).
@@ -304,24 +314,19 @@ def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0
304314 """Block until the TR mobile-app push for this login process is approved."""
305315 if not self ._process_id :
306316 raise ValueError ("Initiate web login first." )
307- url = f"{ self ._host } /api/v2/auth/web/login /processes/{ self ._process_id } "
317+ url = f"{ self ._host } { TR_V2_LOGIN_PATH } /processes/{ self ._process_id } "
308318 deadline = time .time () + timeout_s
309319 print (
310- f"Approve the login in your Trade Republic mobile app "
311- f"(waiting up to { timeout_s } s)..." ,
320+ f"Approve the login in your Trade Republic mobile app (waiting up to { timeout_s } s)..." ,
312321 flush = True ,
313322 )
314- first_body_logged = False
315323 while time .time () < deadline :
316324 r = self ._websession .get (url , headers = self ._auth_headers ())
317325 if r .status_code == 200 :
318326 try :
319327 j = r .json ()
320328 except ValueError :
321329 j = {}
322- if not first_body_logged :
323- self .log .info ("Login poll keys: %s" , sorted (j .keys ()) if j else "<empty>" )
324- first_body_logged = True
325330 state = str (j .get ("state" ) or j .get ("status" ) or "" ).upper ()
326331 if state in ("APPROVED" , "COMPLETED" , "SUCCESS" , "OK" , "DONE" ):
327332 self .save_websession ()
@@ -336,18 +341,18 @@ def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0
336341 self .log .info ("Session cookie detected, login complete." )
337342 return
338343 elif r .status_code in (401 , 403 , 404 , 410 ):
339- raise ValueError (
340- f"Login process rejected or expired ({ r .status_code } ): { r .text [:200 ]} "
341- )
344+ raise ValueError (f"Login process rejected or expired ({ r .status_code } ): { r .text [:200 ]} " )
342345 else :
343346 self .log .warning ("Login poll %s: %s" , r .status_code , r .text [:200 ])
344347 time .sleep (interval_s )
345348 raise TimeoutError ("Push approval not received within timeout." )
346349
347350 def resend_weblogin (self ):
351+ path = TR_V2_LOGIN_PATH if self ._use_v2_login else TR_V1_LOGIN_PATH
352+ headers = self ._auth_headers () if self ._use_v2_login else self ._default_headers
348353 r = self ._websession .post (
349- f"{ self ._host } { TR_WEB_LOGIN_PATH } /{ self ._process_id } /resend" ,
350- headers = self . _auth_headers () ,
354+ f"{ self ._host } { path } /{ self ._process_id } /resend" ,
355+ headers = headers ,
351356 )
352357 r .raise_for_status ()
353358
@@ -357,13 +362,15 @@ def complete_weblogin(self, verify_code):
357362
358363 # v2 push flow: approval already happened during initiate_weblogin().
359364 # An empty verify_code signals nothing left to do.
360- if not verify_code :
365+ if self . _use_v2_login and not verify_code :
361366 self .save_websession ()
362367 return
363368
369+ path = TR_V2_LOGIN_PATH if self ._use_v2_login else TR_V1_LOGIN_PATH
370+ headers = self ._auth_headers () if self ._use_v2_login else None
364371 r = self ._websession .post (
365- f"{ self ._host } { TR_WEB_LOGIN_PATH } /{ self ._process_id } /{ verify_code } " ,
366- headers = self . _auth_headers () ,
372+ f"{ self ._host } { path } /{ self ._process_id } /{ verify_code } " ,
373+ headers = headers ,
367374 )
368375 r .raise_for_status ()
369376 self .save_websession ()
0 commit comments