2121# SOFTWARE.
2222
2323import asyncio
24+ import base64
2425import json
26+ import os
2527import pathlib
2628import re
2729import ssl
4547BASE_DIR = home / ".pytr"
4648CREDENTIALS_FILE = BASE_DIR / "credentials"
4749COOKIES_FILE = BASE_DIR / "cookies.txt"
50+ DEVICE_ID_FILE = BASE_DIR / "device_id"
51+
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.
54+ TR_WEB_APP_VERSION = os .environ .get ("PYTR_TR_APP_VERSION" , "15.7.0" )
55+ TR_WEB_USER_AGENT = os .environ .get (
56+ "PYTR_TR_USER_AGENT" ,
57+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
58+ "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" ,
59+ )
60+ TR_WEB_LOGIN_PATH = "/api/v2/auth/web/login"
4861
4962
5063class TradeRepublicApi :
5164 _default_headers = {
52- "User-Agent" : "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
65+ "User-Agent" : TR_WEB_USER_AGENT ,
66+ "Origin" : "https://app.traderepublic.com" ,
67+ "Referer" : "https://app.traderepublic.com/" ,
5368 }
5469 _host = "https://api.traderepublic.com"
5570 _waf_login_url = "https://app.traderepublic.com/login"
@@ -183,6 +198,46 @@ def _fetch_waf_token_playwright(self, timeout_ms: int = 30000):
183198 self .log .warning ("AWS WAF token not acquired. Value is None." )
184199 return token
185200
201+ def _get_device_id (self ) -> str :
202+ """Return a stable per-install device UUID, creating it on first use."""
203+ try :
204+ return DEVICE_ID_FILE .read_text ().strip ()
205+ except FileNotFoundError :
206+ BASE_DIR .mkdir (parents = True , exist_ok = True )
207+ device_id = uuid .uuid4 ().hex + uuid .uuid4 ().hex # 64 hex chars, matches web app
208+ DEVICE_ID_FILE .write_text (device_id )
209+ return device_id
210+
211+ def _build_device_info_header (self ) -> str :
212+ """Build the base64(JSON) x-tr-device-info payload the web app sends."""
213+ payload = {
214+ "stableDeviceId" : self ._get_device_id (),
215+ "model" : "Apple Macintosh" ,
216+ "browser" : "Chrome" ,
217+ "browserVersion" : "148.0.0.0" ,
218+ "os" : "Mac OS" ,
219+ "osVersion" : "10.15.7" ,
220+ "timezone" : "Europe/Amsterdam" ,
221+ "timezoneOffset" : - 120 ,
222+ "screen" : "1800x1169x30" ,
223+ "preferredLanguages" : ["en" , "en-US" ],
224+ "numberOfCores" : 12 ,
225+ "deviceMemory" : 16 ,
226+ }
227+ raw = json .dumps (payload , separators = ("," , ":" )).encode ("utf-8" )
228+ return base64 .b64encode (raw ).decode ("ascii" )
229+
230+ def _auth_headers (self ) -> Dict [str , str ]:
231+ """Headers required by /api/v2/auth/web/login."""
232+ headers = {
233+ "x-tr-platform" : "web" ,
234+ "x-tr-app-version" : TR_WEB_APP_VERSION ,
235+ "x-tr-device-info" : self ._build_device_info_header (),
236+ }
237+ if self ._waf_token :
238+ headers ["x-aws-waf-token" ] = self ._waf_token
239+ return headers
240+
186241 def _set_waf_cookie (self , token : str ):
187242 """Set the aws-waf-token cookie on the web session."""
188243 cookie = Cookie (
@@ -222,8 +277,9 @@ def initiate_weblogin(self):
222277 self .log .warning ("No WAF token available." )
223278
224279 r = self ._websession .post (
225- f"{ self ._host } /api/v1/auth/web/login " ,
280+ f"{ self ._host } { TR_WEB_LOGIN_PATH } " ,
226281 json = {"phoneNumber" : self .phone_no , "pin" : self .pin },
282+ headers = self ._auth_headers (),
227283 )
228284 self .log .debug (f"Web login returned: { r .status_code } " )
229285 r .raise_for_status ()
@@ -237,20 +293,78 @@ def initiate_weblogin(self):
237293 raise ValueError (str (err ))
238294 else :
239295 raise ValueError ("processId not in reponse" )
240- return int (j ["countdownInSeconds" ]) + 1
296+ self .log .info ("Web login keys: %s" , sorted (j .keys ()))
297+
298+ # v2 web login uses push-to-approve in the TR mobile app: no SMS/code.
299+ # Poll the process endpoint until the push is approved (or rejected/expired).
300+ self .await_web_login_approval ()
301+ return 0
302+
303+ def await_web_login_approval (self , timeout_s : int = 180 , interval_s : float = 2.0 ):
304+ """Block until the TR mobile-app push for this login process is approved."""
305+ if not self ._process_id :
306+ raise ValueError ("Initiate web login first." )
307+ url = f"{ self ._host } /api/v2/auth/web/login/processes/{ self ._process_id } "
308+ deadline = time .time () + timeout_s
309+ print (
310+ f"Approve the login in your Trade Republic mobile app "
311+ f"(waiting up to { timeout_s } s)..." ,
312+ flush = True ,
313+ )
314+ first_body_logged = False
315+ while time .time () < deadline :
316+ r = self ._websession .get (url , headers = self ._auth_headers ())
317+ if r .status_code == 200 :
318+ try :
319+ j = r .json ()
320+ except ValueError :
321+ 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
325+ state = str (j .get ("state" ) or j .get ("status" ) or "" ).upper ()
326+ if state in ("APPROVED" , "COMPLETED" , "SUCCESS" , "OK" , "DONE" ):
327+ self .save_websession ()
328+ self .log .info ("Push approved." )
329+ return
330+ if state in ("REJECTED" , "DECLINED" , "FAILED" , "EXPIRED" ):
331+ raise ValueError (f"Login process { state .lower ()} : { j } " )
332+ # State unknown but session cookie present? Treat as approved.
333+ for c in self ._websession .cookies :
334+ if c .name in ("tr_session" ,) and c .value :
335+ self .save_websession ()
336+ self .log .info ("Session cookie detected, login complete." )
337+ return
338+ 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+ )
342+ else :
343+ self .log .warning ("Login poll %s: %s" , r .status_code , r .text [:200 ])
344+ time .sleep (interval_s )
345+ raise TimeoutError ("Push approval not received within timeout." )
241346
242347 def resend_weblogin (self ):
243348 r = self ._websession .post (
244- f"{ self ._host } /api/v1/auth/web/login /{ self ._process_id } /resend" ,
245- headers = self ._default_headers ,
349+ f"{ self ._host } { TR_WEB_LOGIN_PATH } /{ self ._process_id } /resend" ,
350+ headers = self ._auth_headers () ,
246351 )
247352 r .raise_for_status ()
248353
249354 def complete_weblogin (self , verify_code ):
250355 if not self ._process_id and not self ._websession :
251356 raise ValueError ("Initiate web login first." )
252357
253- r = self ._websession .post (f"{ self ._host } /api/v1/auth/web/login/{ self ._process_id } /{ verify_code } " )
358+ # v2 push flow: approval already happened during initiate_weblogin().
359+ # An empty verify_code signals nothing left to do.
360+ if not verify_code :
361+ self .save_websession ()
362+ return
363+
364+ r = self ._websession .post (
365+ f"{ self ._host } { TR_WEB_LOGIN_PATH } /{ self ._process_id } /{ verify_code } " ,
366+ headers = self ._auth_headers (),
367+ )
254368 r .raise_for_status ()
255369 self .save_websession ()
256370
0 commit comments