Open Source
2026-03

Inoffizielle Scalable Capital API – Portfoliodaten via REST & SSE

Scalable Capital hat keine öffentliche API. Also habe ich mir einen lokalen Proxy gebaut, der Portfoliodaten per REST und SSE bereitstellt – Bewertungen, Kurse, Transaktionen und Live-Streams.

TypeScriptNode.jsPuppeteerGraphQLSSE
Inoffizielle Scalable Capital API – Portfoliodaten via REST & SSE preview

Ich investiere über Scalable Capital und wollte meine Portfoliodaten in einem eigenen Dashboard zusammenführen. Eine öffentliche API gibt es nicht, also habe ich mir einen lokalen Proxy gebaut, der das für mich erledigt.

Das Login-Problem

Das erste Problem war die Authentifizierung. Credentials irgendwo hardcoden wollte ich nicht: MFA, SSO-Cookies, das wird schnell unübersichtlich. Und ein Login-Formular zu scrapen bedeutet, dass es beim nächsten Frontend-Update bricht.

Die Lösung: Puppeteer öffnet ein echtes, sichtbares Chromium-Fenster (headless: false), ich logge mich selbst ein – inklusive 2FA – und das Skript fragt alle 500ms window.location.pathname ab, bis ich nicht mehr auf der Login-Seite bin. Timeout: 120 Sekunden. Danach navigiert es zum Cockpit mit waitUntil: 'networkidle2' und wartet auf Account-Link-Elemente (a[href*="portfolioId="]) im DOM, um sicherzustellen, dass die SPA vollständig gerendert hat.

Aus dem authentifizierten Browser-State werden drei Dinge extrahiert:

  • personId — dekodiert aus dem session-Cookie, der als URL-codiertes JSON das User-Objekt enthält
  • portfolioId — aus dem Href des ersten Cockpit-Links, der auf /portfolioId=([^&]+)/ passt
  • savingsId — aus Hrefs, die auf /\/interest\/([^/?]+)/ matchen; null wenn kein Tagesgeldkonto vorhanden
  • Alle Cookies — aus page.browserContext().cookies(), serialisiert auf Disk

Die Session wird atomar (Temp-Datei + fs.rename()) als session.json mit Modus 0600 geschrieben. Die TTL ist das Minimum aus 8 Stunden und dem frühesten Cookie-Ablauf. Beim nächsten Server-Start prüft loadSessionFromDisk() Date.now() < session.expiresAt und stellt die Session wieder her – ein Neustart erzwingt also kein erneutes Login.

Wenn ein Request 401 oder 403 zurückgibt, startet graphqlRequest() den Puppeteer-Flow neu und wiederholt den Request einmalig. Ein retried-Flag verhindert Endlosschleifen.

Die eigentliche API

Die Scalable Capital App kommuniziert mit einem privaten GraphQL-Endpunkt unter de.scalable.capital/broker/api/data. Jeder Request erhält den vollständigen Cookie-Header sowie weitere Header (Referer, Origin, ein angepasster User-Agent), um wie ein normaler Browser-Request auszusehen. Der Upstream-WebSocket für Echtzeit-Daten liegt unter wss://de.scalable.capital/broker/subscriptions und verwendet das Subprotokoll graphql-transport-ws.

Ich habe das Protokoll manuell mit dem reinen ws-Package implementiert statt eine graphql-ws-Client-Library zu nutzen – damit bleibt die Reconnect- und Subscription-Logik explizit und nachvollziehbar.

WebSocket-Singleton und SSE-Bridge

Das interessanteste Architekturstück: Eine einzelne WebSocketManager-Instanz verwaltet eine persistente Upstream-WebSocket-Verbindung, die von allen verbundenen SSE-Clients geteilt wird.

Wenn die WS-Verbindung aufgebaut ist, sendet sie connection_init und wartet auf connection_ack, dann registriert sie alle ausstehenden Subscriptions. Jede Subscription ist eine { id, type: 'subscribe', payload: { operationName, query, variables } }-Nachricht. Eingehende next-Frames werden anhand der ID an den jeweiligen onData-Callback weitergeleitet. Der Manager behandelt ping/pong-Keep-Alives und reconnectet mit 5 Sekunden Verzögerung, wenn die Verbindung während aktiver Subscriptions abbricht.

Die SSE-Routen kapseln das mit einer dünnen Fan-out-Schicht:

[SSE-Client 1] ──┐
[SSE-Client 2] ──┤── SubscriptionManager ── einzelne WS-Sub ── Upstream WS
[SSE-Client N] ──┘

Wenn der letzte SSE-Client die Verbindung trennt, wird die Subscription entfernt und der WS abgebaut. Beim ersten Client-Connect wird er lazy aufgebaut.

Quote-Subscriptions sind ausgefeilter: QuoteManager verwaltet die Vereinigungsmenge aller ISINs aller aktuell verbundenen Clients. Wenn ein neuer Client mit einer anderen ISIN-Menge subscribed, sendet er complete für die alte Subscription-ID und registriert sofort eine neue mit der zusammengeführten Menge. Jeder Tick wird dann pro Client nach dessen angefragten ISINs gefiltert, bevor er weitergeleitet wird.

Die REST/WS-Bridge: GET /portfolio ruft subscriptionManager.fetchLatest() auf. Ist ein gecacheter Valuation-Tick fresher als 30 Sekunden, wird er sofort zurückgegeben. Sonst wartet die Methode bis zu 10 Sekunden auf die nächste WS-Nachricht – der WebSocket ist so für HTTP-Clients vollständig transparent.

Endpunkte

Der lokale Express-Server stellt folgende Endpunkte bereit:

Auth

  • POST /auth/login — öffnet das Puppeteer-Fenster; überspringt den Re-Login bei gültiger Session
  • GET /auth/status — gibt den Session-Status zurück ohne Login auszulösen
  • DELETE /auth/logout — löscht den Speicher und session.json

Portfolio

  • GET /portfolio — aktuelle Bewertung (aus WS-Cache, 30s TTL)
  • GET /portfolio/inventory — vollständige Positionen und Sparpläne
  • GET /portfolio/cash — Kaufkraft und Auszahlungskraft
  • GET /portfolio/timeseries — zeitgewichtete Rendite-Datenpunkte
  • und weitere: /watchlist, /interest-rates, /pending-orders, /appropriateness, /crypto-performance

Wertpapiere

  • GET /securities/:isin — vollständige Details mit Handelbarkeit und Live-Kurs-Tick
  • GET /securities/:isin/timeseries — Kurshistorie; ?timeframes=ONE_WEEK,ONE_MONTH,...
  • und weitere: /info, /static, /tick, /tradability, /buyable

Transaktionen & Tagesgeld

  • GET /transactions — cursor-paginiert; filterbar nach ISIN, Typ, Status, Suchbegriff
  • GET /savings und GET /savings/transactions — Tagesgeld-Kontostand und -Geschichte

Streaming

  • GET /valuation/stream — SSE: Live-Portfolio-Bewertungs-Ticks
  • GET /quotes/stream?isins=ISIN1,ISIN2 — SSE: Live Bid/Ask/Mid-Kurs-Ticks

Sonstiges

  • POST /proxy — roher GraphQL-Durchgriff für Ad-hoc-Queries
  • GET /docs — interaktive Scalar-UI, generiert aus der OpenAPI-3.1.0-Spec
  • GET /health{ status: 'ok' }

Die vollständige API ist in openapi.yaml dokumentiert und nach dem Server-Start unter /docs abrufbar.

API-Änderungserkennung

Ein optionales --monitor-Flag aktiviert Drift-Detection. Bei jeder GraphQL-Antwort und jeder WS-Nachricht extrahiert das Tool eine strukturelle "Form" des Payloads – indem es Werte rekursiv auf ihre Typen abbildet ('string', 'number', 'array' oder eine verschachtelte Form-Map). Beim ersten Auftreten wird die Form in api-snapshot.json gespeichert. Bei jedem weiteren Auftreten wird gegen die Baseline gedifft, und Änderungen (Feld hinzugefügt, Feld entfernt, Typ geändert) werden mit Zeitstempel und JSON-Pfad in api-changes.json angehängt. Beide Dateien werden atomar geschrieben. Der Snapshot ist als bekannter Stand im Repo eingecheckt.

Sicherheit

Der Server bindet ausschließlich an 127.0.0.1 – nie erreichbar im Netzwerk. Ein optionales --token-CLI-Flag aktiviert einen X-Gateway-Token-Header-Pflicht für alle Routen außer /auth und /docs. session.json ist gitignored und wird mit 0600-Berechtigungen geschrieben.


Kein offizielles Projekt, keine Verbindung zu Scalable Capital. Nutzung auf eigene Verantwortung.