Open Source
2026-03

Unofficial Scalable Capital API: Portfolio Data via REST and SSE

Scalable Capital doesn't have a public API. So I built a local proxy that exposes portfolio data via REST and SSE: valuations, quotes, transactions, and live streams.

TypeScriptNode.jsPuppeteerGraphQLSSE
Unofficial Scalable Capital API: Portfolio Data via REST and SSE preview

I invest through Scalable Capital and wanted my portfolio data in my own dashboard. There's no public API, so I built a local proxy.

The Login Problem

The first obstacle was authentication. Hardcoding credentials wasn't an option: MFA, SSO cookies, it gets complicated quickly. And scraping a login form means breaking whenever the frontend changes.

The solution: Puppeteer opens a real headed Chromium window (headless: false), I log in myself, including 2FA, and the script polls window.location.pathname every 500ms until I'm no longer on the login page. Timeout is 120 seconds. Then it navigates to the cockpit with waitUntil: 'networkidle2' and waits for account link elements (a[href*="portfolioId="]) to appear in the DOM, until the SPA has fully loaded.

Four things are extracted from the authenticated browser state:

  • personId: decoded from the session cookie, which is URL-encoded JSON containing the user object
  • portfolioId: scraped from the href of the first cockpit link matching /portfolioId=([^&]+)/
  • savingsId: scraped from hrefs matching /\/interest\/([^/?]+)/; null if no savings account
  • all cookies: from page.browserContext().cookies(), serialized to disk

The session is written atomically to session.json with mode 0600, via a temp file and fs.rename(). The TTL is the minimum of 8 hours and the earliest cookie expiry. On the next server start, loadSessionFromDisk() checks Date.now() < session.expiresAt and restores the session, so a restart doesn't force a re-login.

If any request returns a 401 or 403, graphqlRequest() re-runs the Puppeteer flow and retries once. A retried flag prevents loops.

The Actual API

Scalable Capital's app talks to a private GraphQL endpoint at de.scalable.capital/broker/api/data. Each request gets the full cookie header plus additional headers (Referer, Origin, a custom User-Agent) to look like a normal browser request. The upstream WebSocket for real-time data lives at wss://de.scalable.capital/broker/subscriptions using the graphql-transport-ws subprotocol.

I implemented the protocol by hand with the raw ws package rather than using the graphql-ws client library. This keeps the reconnect and subscription management logic explicit and auditable.

WebSocket Singleton and SSE Bridge

The most interesting architectural piece: a single WebSocketManager instance manages one persistent upstream WebSocket connection, shared across all connected SSE clients.

After the connection opens, the manager sends connection_init, waits for connection_ack, and then registers all pending subscriptions. Each subscription is a { id, type: 'subscribe', payload: { operationName, query, variables } } message. Incoming next frames are routed to the matching subscription's onData callback. The manager handles ping/pong keep-alives and reconnects after 5 seconds if the connection drops while there are active subscriptions.

The SSE routes wrap this with a thin fan-out layer:

[SSE client 1] ──┐
[SSE client 2] ──┤── SubscriptionManager ── single WS sub ── upstream WS
[SSE client N] ──┘

When the last SSE client disconnects, the subscription is removed and the WebSocket connection is torn down. The first client to connect triggers the lazy setup.

Quote subscriptions are smarter: QuoteManager maintains the union of all ISINs requested by all current clients. When a new client subscribes with a different ISIN set, it sends complete for the old subscription id and registers a new one with the merged set. Each tick is then filtered per-client by requested ISINs before forwarding.

The REST/WS bridge: GET /portfolio calls subscriptionManager.fetchLatest(). If a cached valuation tick is fresher than 30 seconds, it's returned immediately. Otherwise the method waits up to 10 seconds for the next WS message, making the WebSocket subscription transparent to HTTP clients.

Endpoints

The local Express server exposes:

Auth

  • POST /auth/login: opens the Puppeteer window; skips re-login if a valid session exists
  • GET /auth/status: returns session state without triggering login
  • DELETE /auth/logout: clears memory and deletes session.json

Portfolio

  • GET /portfolio: current valuation (from WS cache, 30s TTL)
  • GET /portfolio/inventory: full holdings with positions and savings plans
  • GET /portfolio/cash: buying power and withdrawal power
  • GET /portfolio/timeseries: time-weighted return data points
  • and more: /watchlist, /interest-rates, /pending-orders, /appropriateness, /crypto-performance

Securities

  • GET /securities/:isin: full detail including tradability and live quote tick
  • GET /securities/:isin/timeseries: price history; ?timeframes=ONE_WEEK,ONE_MONTH,...
  • and more: /info, /static, /tick, /tradability, /buyable

Transactions and Savings

  • GET /transactions: cursor-paginated; filterable by ISIN, type, status, search term
  • GET /savings and GET /savings/transactions: overnight account balance and history

Streaming

  • GET /valuation/stream: SSE, live portfolio valuation ticks
  • GET /quotes/stream?isins=ISIN1,ISIN2: SSE, live bid/ask/mid quote ticks

Other

  • POST /proxy: raw GraphQL passthrough for ad-hoc queries
  • GET /docs: interactive Scalar UI built from the OpenAPI 3.1.0 spec
  • GET /health: { status: 'ok' }

The full API is documented in openapi.yaml and served at /docs after starting the server.

API Change Detection

An optional --monitor flag enables drift detection. On every GraphQL response and every WS message, the tool extracts the structural shape of the payload by recursively mapping values to their types ('string', 'number', 'array', or a nested shape map). On first encounter it saves the shape to api-snapshot.json. On subsequent encounters it compares against the baseline and appends any changes (field added, field removed, type changed) to api-changes.json with a timestamp and JSON path. Both files are written atomically. The snapshot is checked into the repo as a known-good baseline.

Security

The server binds to 127.0.0.1 only, so it's never reachable on the network. An optional --token CLI flag enables an X-Gateway-Token header requirement on all routes except /auth and /docs. session.json is git-ignored and written with 0600 permissions.


Unofficial project, no affiliation with Scalable Capital. Use at your own risk.