中文 · English
A Node & browser client for the private thinkorswim WebSocket API. A modernization
of upstream huskly/tos-wsjson-client v0.12.0: pnpm + TypeScript 7 + Node 24, with
every service corrected against live black-box testing. Protocol details, service
parameters and findings are in TECHNICAL.md.
⚠️ Charles Schwab International does not offer developer accounts; if you need programmatic trading, evaluate the official API first. The wsjson protocol used here is the web client's internal API, unsupported and subject to change at any time. The current implementation has been verified on a live account, but continued compatibility is not guaranteed. Always bring your own risk controls when automating a live account.
- Node ≥ 24 (pnpm 12 auto-enabled via corepack)
corepack enable
pnpm install
pnpm build # tsc → dist/
pnpm test # jest (incl. real-wire frame replay)
pnpm run ci # prettier + build + lint + testCredentials: copy scripts/.env.blackbox.example to scripts/.env.blackbox and
fill it in (untracked).
# Anonymous handshake probe (no credentials)
node scripts/probe-ws.mjs 10000
# Paper: harvest token → end-to-end smoke → place/cancel smoke ($1 limit, auto-cancel)
node scripts/auth-guest.mjs <user> <pass>
node scripts/smoke-lib.mjs
node scripts/smoke-order.mjs
# Read-only service census / write cases (paper-only, enforced by the tools)
node scripts/probe-services.mjs [service ...]
node scripts/probe-write.mjs W1|W2|W4|…
# Real account (headless by default; F2A handled automatically: approve the mobile
# push on your phone, or type the SMS code in the terminal; --headed for manual)
node scripts/auth-real.mjs
node scripts/smoke-real.mjs # read-only smoke
node scripts/exp-auth-refresh.mjs # token refresh regression
# Coverage audit / headless fingerprint matrix
node scripts/audit-coverage.mjs
node scripts/exp-fp.mjsLegacy examples (username/password path + local proxy):
node --env-file=.env dist/example/testApp.js
node --env-file=.env dist/example/wsProxyServer.jsThe local proxy is unauthenticated by default — any local process can drive the session it proxies. Setting
TOS_PROXY_TOKENrequires every proxied message to carry that token, otherwise the connection is dropped. Required on shared machines and any non-loopback setup.
- After logging into
https://trade.thinkorswim.com(or/guestpass), the web client exchanges the oauthcodeattsm/authorizefor a new authCode — that new authCode is what wsjson needs (the raw oauth code is consumed by authorize). - WS
login/schwabtrades the authCode for a 32-char session token; the refreshToken is only minted when "trust device" is checked during F2A. - The session token is reusable across WS connections (~24h). Real-account F2A
login can run fully headless (
scripts/auth-real.mjs). - Tokens are bound to a data center: paper must use
wss://papermoney-services.schwab.com/Services/WsJson, real accountsthinkorswim-services; wrong DC →Invalid token (server session id). Select vianew RealWsJsonClient({ wsUrl: PAPER_WS_URL }).
| Domain | Capabilities |
|---|---|
| Auth | access token / authCode login; getAccessToken refresh; auth_code/get |
| Accounts | accounts / statement / positions / margin calls / disclosures / user properties / order history / tax lots / transaction categories |
| Quotes | quote stream & one-shot quotes · candles · market depth · market hours |
| Options | option chain · chain quotes · chain details · option statistics |
| Fundamentals | current/history fundamentals · study catalog (441) · study value subscription |
| News | headers / relevant news / body / subscriptions |
| Screener | filter & parameters / query |
| Drawings | drawing CRUD · drawing sets |
| Search | instrument search · analyst reports |
| Trading | LIMIT/MARKET/STOP/STOPLIMIT orders (two-step CONFIRM→SUBMIT internally) · modify/cancel · order event stream (raw + normalized status) · safety guard |
| Alerts | create / cancel / lookup / subscribe |
| Watchlists | create / rename / delete / add / remove / rearrange |
| Message ctr | fetch / subscribe |
| Other | subscription cleanup · support chat · demo account top-up ( |
Built into the connection layer: exponential-backoff reconnect, heartbeat watchdog,
handshake/login timeouts, subscription replay after reconnect; early break in
for-await sends unsubscribe automatically; all errors are Error subclasses
(WebSocketApiError / AuthError / TransportError / GuardError).
import {
RealWsJsonClient,
PAPER_WS_URL,
WebSocketApiError,
} from "tos-wsjson-client";
const client = new RealWsJsonClient({ wsUrl: PAPER_WS_URL });
await client.authenticateWithAccessToken({ accessToken });
const { body } = await client.getAccounts();
for await (const { body: q } of client.quotes(["AAPL", "SPY"])) {
console.log(q.items);
break; // first frame is enough
}
// Place an order (two-step internally; server errors reject as WebSocketApiError)
try {
const { body: placed } = await client.placeOrder({
accountNumber: "<paper-account-code>", // via getAccounts()
symbol: "AAPL",
quantity: 1,
limitPrice: 340.0,
});
console.log("orderId", placed.orderId);
} catch (e) {
if (e instanceof WebSocketApiError) console.error("server:", e.message);
}
// Order event stream: normalized status (WORKING/FILLED/CANCELED/…)
for await (const event of client.observeOrders("<paper-account-code>")) {
console.log(event.orderId, event.status, event.filledQuantity);
}RealWsJsonClientOptions.guard validates at the order/modify/cancel entry points;
a violation throws GuardError and nothing reaches the wire:
const client = new RealWsJsonClient({
wsUrl: PAPER_WS_URL,
guard: {
paperOnly: true, // papermoney endpoint only
allowedAccountCodes: ["<paper-account-code>"],
maxQuantity: 100,
maxNotionalUsd: 5000,
},
});More in src/example/testApp.ts and scripts/smoke-*.mjs.
- Fractional (notional) buy orders only work reliably as MARKET + a decimal
legs.quantity(TECHNICAL.md §5.3): there is noAMOUNTorder type;isNotionalis a trap (rejected at CONFIRM, and at SUBMIT the paper endpoint accepts it but fills whole shares); minimum $1 and ≤ 4 decimal places; round quantity withfloorso the cost never exceeds the planned notional. Paper's fractional semantics differ from live and cannot be used to verify them. - Some services are permission-gated for guest sessions (relevant news, screener, tax-lot deep lookups); real accounts work. Write endpoints were not automated against real accounts by test discipline.
- Token auto-refresh (
src/auth/authenticator.ts): the accessToken minted byaccess_token/getis an EAT, not used by the wsjson login frame — an expired session token requires re-running the login chain. The refreshToken rotates one-time; persist the new value inonTokenRotated. market_depth: accepted but silent in a bare real-account session (the web client runs a larger flow with extra subscription context); guest gets a snapshot.- The private protocol drifts: the server build keeps iterating and parameters,
enums and error shapes may change. Re-census anytime with
pnpm run ciandscripts/probe-services.mjs.