A Node.js wrapper for Schwab's APIs with OAuth, market data, account endpoints, options helpers, and streaming support.
npm install @misterpea/schwab-nodeVisit https://developer.schwab.com, create an app, then add these variables to your project root:
SCHWAB_CLIENT_SECRET=A1B2C3D4E5F6G7H8
SCHWAB_CLIENT_ID=ABCDEFGHIJKLMNOPQRSTUVWXZY123456
SCHWAB_REDIRECT_URI=https://127.0.0.1:8443The redirect URI must be local HTTPS with an explicit port.
You can also point auth at a different env file later via paths.envPath.
npx schwab-node-certs --callback https://127.0.0.1:8443The cert script prefers mkcert when available and falls back to openssl otherwise.
You can override the default paths with --env-path and --storage-root when needed.
import { getQuote } from "@misterpea/schwab-node";
const quote = await getQuote({
symbols: ["AAPL"],
fields: "quote",
});
console.log(quote.AAPL?.quote?.lastPrice);Streaming subscriptions are delivered locally through ZeroMQ. SchwabStreamer maintains the Schwab WebSocket connection, then publishes normalized messages that local subscribers can consume.
import { SchwabStreamer, createSubscriber, listen } from "@misterpea/schwab-node";
const streamer = new SchwabStreamer();
await streamer.connect();
await streamer.login();
const subscriber = await createSubscriber("tcp://localhost:5555", ["schwab"]);
await listen(subscriber, (topic, message) => {
console.log(topic, message);
});
await streamer.subsL1Equities({
keys: ["AAPL"],
fields: ["symbol", "bidPrice", "askPrice", "lastPrice", "quoteTime"],
});Setup notes:
- Default authenticated requests load credentials from
.env. - Tokens and callback metadata are stored under
.secrets/. - The cert setup command also saves the callback URL and adds
.secrets/to.gitignore. paths.envPathandpaths.storageRootlet hosts override those defaults without changing call sites elsewhere in the package.
Import from the package root for the main request/response API:
import {
getQuote,
getPriceHistory,
getMovers,
getMarketHours,
getOptionChain,
getOptionExpirations,
getAtmOptionData,
greekFilter,
getAccounts,
getAccountNumbers,
getUserPreference,
} from "@misterpea/schwab-node";| Export | Description | Returns |
|---|---|---|
getQuote(config) |
Quote and/or fundamentals for one or more symbols | Promise<GetQuotesResponse> |
getPriceHistory(config) |
Price history candles for a symbol | Promise<GetPriceHistoryResponse | undefined> |
getMovers(config) |
Top movers for an index | Promise<ScreenersResponse> |
getMarketHours(config) |
Market hours for one or more markets | Promise<MarketHoursRtn[]> |
getOptionChain(config) |
Option chain keyed by expiration and strike | Promise<GetOptionChainReturn | undefined> |
getOptionExpirations(config) |
Available expirations for a symbol | Promise<OptionExpirationReturn | undefined> |
getAtmOptionData(config) |
Near-the-money options in an inclusive DTE window | Promise<GetAtmOptionReturn | undefined> |
greekFilter(config) |
Options filtered by DTE and Greek ranges | Promise<GreekFilterReturn> |
getAccounts() |
Account info including balances and buying power | Promise<AccountsResponse> |
getAccountNumbers() |
Account numbers and their encrypted values | Promise<UserAccountNumbers> |
getUserPreference() |
Account and streamer metadata | Promise<UserPreferenceResponse> |
Validation notes:
getPriceHistory(),getOptionChain(),getOptionExpirations(), andgetAtmOptionData()validate request input before calling Schwab.- When validation fails, those functions log a validation tree and return
undefined. greekFilter()returns an empty array on invalid filter input.- Successful responses are parsed before being returned.
Use SchwabStreamer when you want live subscriptions. The package uses ZeroMQ as the local delivery layer: SchwabStreamer handles the Schwab WebSocket session and publishes messages that your local subscriber consumes.
import { SchwabStreamer, createSubscriber, listen } from "@misterpea/schwab-node";Simple flow:
new SchwabStreamer()await streamer.connect()await streamer.login()- connect a local ZeroMQ subscriber
- subscribe with one of the
subs...methods
The main README focuses on the subscription surface. Transport details and field maps are lower in the document.
Use SchwabAuth when you want direct control over token lifecycle instead of relying on default .env loading.
import { SchwabAuth } from "@misterpea/schwab-node";import { getQuote, GetQuoteRequest } from "@misterpea/schwab-node";
const config: GetQuoteRequest = {
symbols: ["AAPL", "NVDA"],
fields: "fundamental",
};
const quote = await getQuote(config);Example response shape:
{
AAPL: {
symbol: "AAPL",
assetMainType: "EQUITY",
fundamental: {
peRatio: 33.01258,
eps: 7.46,
sharesOutstanding: 14681140000,
},
},
NVDA: {
symbol: "NVDA",
assetMainType: "EQUITY",
fundamental: {
peRatio: 37.41633,
eps: 4.9,
sharesOutstanding: 24296000000,
},
},
}import { getPriceHistory, GetPriceHistoryRequest } from "@misterpea/schwab-node";
const config: GetPriceHistoryRequest = {
symbol: "GILD",
periodType: "month",
period: 1,
frequencyType: "daily",
frequency: 1,
};
const priceHistory = await getPriceHistory(config);Example response shape:
{
symbol: "GILD",
empty: false,
candles: [
{
open: 146.5,
high: 150.5,
low: 145.87,
close: 149.37,
volume: 9143045,
datetime: 1770271200000,
},
{
open: 149.69,
high: 153.13,
low: 148.7082,
close: 152.5,
volume: 8510037,
datetime: 1770357600000,
},
],
}import { getMovers, GetMoversConfig } from "@misterpea/schwab-node";
const config: GetMoversConfig = {
index: "$SPX",
sort: "PERCENT_CHANGE_DOWN",
};
const spxMovers = await getMovers(config);Example response shape:
[
{
symbol: "NVDA",
description: "NVIDIA CORP",
lastPrice: 177.82,
netChange: -5.52,
netPercentChange: -0.0301,
},
]import { getMarketHours, GetMarketHoursConfig } from "@misterpea/schwab-node";
const config: GetMarketHoursConfig = {
markets: ["equity", "option"],
date: "2026-03-11",
};
const hours = await getMarketHours(config);Example response shape:
[
{
date: "2026-03-11",
marketType: "EQUITY",
isOpen: true,
sessionHours: {
regularMarket: [
{
start: "2026-03-11T09:30:00-04:00",
end: "2026-03-11T16:00:00-04:00",
},
],
},
},
]import { getOptionChain, GetOptionChainRequest } from "@misterpea/schwab-node";
const config: GetOptionChainRequest = {
symbol: "AAPL",
contractType: "CALL",
strikeCount: 2,
fromDate: "2026-03-09",
toDate: "2026-03-10",
};
const optionChain = await getOptionChain(config);Example response shape:
{
symbol: "AAPL",
status: "SUCCESS",
underlyingPrice: 257.35,
callExpDateMap: {
"2026-03-09:3": {
"255.0": [
{
putCall: "CALL",
symbol: "AAPL 260309C00255000",
bid: 3.6,
ask: 3.75,
strikePrice: 255,
delta: 0.664,
},
],
},
},
putExpDateMap: {},
}import { getOptionExpirations, OptionExpirationRequest } from "@misterpea/schwab-node";
const expirations = await getOptionExpirations({
symbol: "AAPL",
});Example response shape:
[
{
expirationDate: "2026-03-13",
daysToExpiration: 7,
expirationType: "W",
settlementType: "P",
},
]import { getAtmOptionData, GetAtmOptionRequest } from "@misterpea/schwab-node";
const config: GetAtmOptionRequest = {
symbol: "AAPL",
window: [7, 21],
};
const atmData = await getAtmOptionData(config);Example response shape:
[
{
put_call: "CALL",
day_of_expiry: "FRI",
underlying: "AAPL",
symbol: "AAPL 260313C00257500",
dte: 7,
strike_price: 257.5,
delta: 0.501,
bid: 4.3,
ask: 4.85,
},
]import { greekFilter, GreekFilterRequest } from "@misterpea/schwab-node";
const config: GreekFilterRequest = {
symbol: "GILD",
window: [14, 21],
greek: {
iv: [29, 30],
vega: [0.05, 0.15],
absDelta: [0.35, 0.49],
},
};
const filtered = await greekFilter(config);Example response shape:
[
{
put_call: "CALL",
day_of_expiry: "THR",
underlying: "GILD",
symbol: "GILD 260320C00144000",
dte: 14,
strike_price: 144,
volatility: 29.438,
vega: 0.11,
delta: 0.471,
},
]import { getAccounts } from "@misterpea/schwab-node";
const accounts = await getAccounts();Example response shape:
[
{
securitiesAccount: {
type: "MARGIN",
accountNumber: "12345678",
currentBalances: {
liquidationValue: 100000.75,
buyingPower: 100000,
cashBalance: 100000.5,
},
},
aggregatedBalance: {
liquidationValue: 100000.75,
},
},
]import { getAccountNumbers } from "@misterpea/schwab-node";
const accountNumbers = await getAccountNumbers();Example response shape:
[
{
accountNumber: "12345678",
hashValue: "0123456789ABCDEFGH01234567890123456789ABCDEFGH0123456789",
},
]import { getUserPreference } from "@misterpea/schwab-node";
const userPreference = await getUserPreference();Example response shape:
{
accounts: [
{
accountNumber: "12345678",
type: "BROKERAGE",
displayAcctId: "...678",
},
],
streamerInfo: [
{
streamerSocketUrl: "wss://streamer-api.schwab.url/websocket",
schwabClientChannel: "A1",
schwabClientFunctionId: "APIAPP",
},
],
offers: [
{
level2Permissions: true,
mktDataPermission: "NP",
},
],
}Get streaming data from Schwab through the Streaming API.
- The WebSocket streamer uses ZeroMQ to handle message delivery. This allows users to consume the streaming data with components built in any language that has ZeroMQ bindings.
import { SchwabStreamer, createSubscriber, listen } from "@misterpea/schwab-node";
const streamer = new SchwabStreamer();
await streamer.connect();
await streamer.login();
const subscriber = await createSubscriber("tcp://localhost:5555", ["schwab"]);
await listen(subscriber, (topic, message) => {
console.log(topic, message);
});
await streamer.subsL1Equities({
keys: ["AAPL"],
fields: ["symbol", "bidPrice", "askPrice", "lastPrice", "quoteTime"],
});Common subscription entry points:
subsL1EquitiessubsL1OptionssubsL1FuturessubsL1FuturesOptionssubsL1ForexsubsL2NyseBooksubsL2NasdaqBooksubsL2OptionsBooksubsChartEquitysubsChartFuturessubsScreenerEquitysubsScreenerOptionsubsAcctActivity
If you want the transport details, field maps, or raw adapter helpers, they are documented below.
Advanced Streaming
The WebSocket streamer uses ZeroMQ for local message delivery. By default, the publish side binds on tcp://*:5555, and the package exports helpers for local subscribers.
import { createSubscriber, listen } from "@misterpea/schwab-node";
const subscriber = await createSubscriber("tcp://localhost:5555", ["schwab"]);
await listen(subscriber, (topic, message) => {
console.log(topic, message);
});Field-map helpers are also exported for users building adapters on top of raw streamer payloads:
LEVELONE_EQUITIES_FIELDSLEVELONE_OPTIONS_FIELDSLEVELONE_FUTURES_FIELDSLEVELONE_FUTURES_OPTIONS_FIELDSLEVELONE_FOREX_FIELDSBOOK_FIELDSBOOK_PRICE_LEVEL_FIELDSBOOK_MARKET_MAKER_FIELDSCHART_EQUITY_FIELDSCHART_FUTURES_FIELDSSCREENER_FIELDSACCT_ACTIVITY_FIELDS- inverse maps and resolver helpers such as
resolveFieldIds()andresolveFieldNames()
Example:
import {
LEVELONE_FUTURES_FIELDS,
SchwabStreamer,
} from "@misterpea/schwab-node";
const streamer = new SchwabStreamer();
await streamer.connect();
await streamer.login();
await streamer.subsL1Futures({
keys: ["/ESH26"],
fields: ["symbol", "bidPrice", "askPrice", "lastPrice", "quoteTime"],
});
console.log(LEVELONE_FUTURES_FIELDS["10"]); // "quoteTime"Use HistoricalReplayStreamer when you want to replay file-backed OHLCV data through the same local ZeroMQ transport as live streaming, while keeping replay data explicitly separate from live Schwab services.
Historical replay publishes type: "data" messages on historical service topics such as schwab.data.HISTORICAL_CHART_EQUITY. That separation is intentional: downstream consumers can hard-check the service name and refuse to place live trades when they are connected to replayed data.
import { HistoricalReplayStreamer, createSubscriber, listen } from "@misterpea/schwab-node";
const replay = new HistoricalReplayStreamer();
const subscriber = await createSubscriber("tcp://localhost:5555", ["schwab.data.HISTORICAL_"]);
void listen(subscriber, (topic, message) => {
console.log(topic, message);
});
await replay.replayFile({
service: "HISTORICAL_CHART_EQUITY",
inSampleFiles: [
"/absolute/path/to/2025-01-02.jsonl",
"/absolute/path/to/2025-01-03.jsonl",
],
preSampleFiles: [
"/absolute/path/to/2024-12-30.jsonl",
"/absolute/path/to/2024-12-31.jsonl",
],
outOfSampleFiles: [
"/absolute/path/to/2025-01-06.jsonl",
"/absolute/path/to/2025-01-07.jsonl",
"/absolute/path/to/2025-01-08.jsonl",
],
outOfSampleWindowSize: 2,
outOfSampleOverlap: 1,
pace: "burst",
});Replay config:
| Field | Description | Default |
|---|---|---|
service |
Base historical service name to publish from | HISTORICAL_CHART_EQUITY |
inSampleFiles |
Absolute historical file paths for the required in-sample section | Required in split mode |
preSampleFiles |
Absolute file paths for the optional pre-sample warmup section | None |
outOfSampleFiles |
Absolute file paths for optional out-of-sample cascading windows | None |
outOfSampleWindowSize |
File count per out-of-sample window | Required when outOfSampleFiles is set |
outOfSampleOverlap |
File overlap count between out-of-sample windows | Required when outOfSampleFiles is set |
filePath |
Legacy path to one historical .jsonl or .csv file |
Optional legacy mode |
symbol |
Fallback symbol when rows do not include one | None |
format |
Explicit source format override | Auto-detected from extension |
pace |
burst or timed replay |
burst |
speed |
Replay speed multiplier for timed mode | 1 |
Current source handling:
- Split mode accepts absolute file paths from a file picker and replays them in the exact order provided.
inSampleFilespublishes to<service>_IN_SAMPLE.preSampleFilespublishes to<service>_PRE_SAMPLEwhen provided.outOfSampleFilespublishes overlapping cascades to services like<service>_OO_SAMPLE_1,<service>_OO_SAMPLE_2, and adds payload metadata includingbaseService,sectionLabel,sectionIndex, andsectionKind.- Legacy
filePathmode is still supported for one-file replay and cannot be combined with split replay fields. .jsonlexpects one OHLCV record per line with fields such asopen,high,low,close,volume, anddatetime..csvsupports the included vendor-style test fixture shape withts_eventnanosecond timestamps and scaled integer OHLC values.- Symbol resolution uses row symbol first, then the explicit
symbolconfig, then filename inference.
Example published message:
{
"type": "data",
"receivedAt": 1743177600000,
"payload": {
"service": "HISTORICAL_CHART_EQUITY_IN_SAMPLE",
"command": "REPLAY",
"content": [
{
"symbol": "TESTCSV",
"openPrice": 101.25,
"highPrice": 101.5,
"lowPrice": 101,
"closePrice": 101.4,
"volume": 300,
"chartTime": 1802000000000
}
],
"source": "2025-01-02.jsonl,2025-01-03.jsonl",
"replayMode": "burst",
"baseService": "HISTORICAL_CHART_EQUITY",
"sectionLabel": "IN_SAMPLE",
"sectionIndex": 1,
"sectionKind": "in_sample"
}
}Notes:
- Historical replay does not reuse live Schwab service names like
CHART_EQUITY; it always usesHISTORICAL_*service values. - The current ZMQ adapter remains focused on live Schwab numeric field remapping and is not used for replay normalization.
- Initial scope is OHLCV replay. Additional historical service shapes can be added later without changing the live stream contract.
Most users can rely on default auth loaded from .env. Use SchwabAuth directly when you want to control token acquisition and refresh explicitly.
import { SchwabAuth } from "@misterpea/schwab-node";
const auth = new SchwabAuth({
clientId: "your-client-id",
clientSecret: "your-client-secret",
redirectUri: "https://127.0.0.1:8443",
});
const tokenInfo = await auth.getAuth();Auth resolution order is:
- explicit
clientId,clientSecret, andredirectUri - optional
secretsprovider functions - the env file resolved from
paths.envPath
Path resolution defaults are:
envPath:./.envstorageRoot:./.secretstokenPath:${storageRoot}/tokencertsDir:${storageRoot}/certscallbackUrlPath:${storageRoot}/callback-url
Use resolveSchwabPaths() when the host application needs to inspect or share the package's resolved filesystem layout without reimplementing its path rules.
import { resolveSchwabPaths } from "@misterpea/schwab-node";
const paths = resolveSchwabPaths({
cwd: process.cwd(),
envPath: "./config/schwab.env",
storageRoot: "./runtime/schwab-secrets",
});
console.log(paths.envPath);
console.log(paths.tokenPath);
console.log(paths.certsDir);resolveSchwabPaths() only returns the resolved paths. It does not reconfigure auth or requests on its own.
Pass the result into SchwabAuth or configureDefaultAuth to make the package use those locations:
import {
SchwabAuth,
configureDefaultAuth,
resolveSchwabPaths,
} from "@misterpea/schwab-node";
const paths = resolveSchwabPaths({
cwd: process.cwd(),
envPath: "./.config/schwab.env",
storageRoot: "./.config",
});
const auth = new SchwabAuth({ paths });
configureDefaultAuth({ paths });Returned shape:
type SchwabPaths = {
cwd: string;
envPath: string;
storageRoot: string;
tokenPath: string;
certsDir: string;
callbackUrlPath: string;
};resolveSchwabPaths() returns absolute paths. Relative overrides are resolved from cwd, which defaults to process.cwd().
Use paths when the host app needs a different env file or storage directory while keeping the rest of the package autonomous (think electron.js or maybe react-native).
import { SchwabAuth } from "@misterpea/schwab-node";
const auth = new SchwabAuth({
paths: {
envPath: "./config/schwab.env",
storageRoot: "./runtime/schwab-secrets",
},
});Use tokenStore when tokens should not live in the default plaintext file store.
import {
EncryptedFileTokenStore,
SchwabAuth,
type TokenCipher,
} from "@misterpea/schwab-node";
const cipher: TokenCipher = {
async encrypt(plainText) {
return myEncrypt(plainText);
},
async decrypt(cipherText) {
return myDecrypt(cipherText);
},
};
const auth = new SchwabAuth({
tokenStore: new EncryptedFileTokenStore(
"/secure/location/schwab-token.enc",
cipher,
),
secrets: {
getClientId: () => process.env.SCHWAB_CLIENT_ID,
getClientSecret: () => myKeychain.read("schwab-client-secret"),
getRedirectUri: () => "https://127.0.0.1:8443/callback",
},
});Token shape:
{
"expires_in": 1800,
"token_type": "Bearer",
"scope": "api",
"refresh_token": "bbbbbb-aaaaaa-zzzzzzz_yyyyyyy-xxxxx@",
"access_token": "I0.aaaaaa.bbbbbb_cccccc@",
"id_token": "abcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz.abcdefghijklm-nopqrstuvwxyz",
"obtained_at": 946684800000,
"refresh_obtained_at": 946684800000
}The package root is the recommended import path for most users.
import {
getQuote,
getPriceHistory,
getOptionChain,
getAccounts,
SchwabAuth,
SchwabStreamer,
} from "@misterpea/schwab-node";Namespace subpaths
Use subpaths when you want a namespace boundary in your imports:
import { getQuote, getPriceHistory } from "@misterpea/schwab-node/market-data";
import { getOptionChain, greekFilter } from "@misterpea/schwab-node/derivatives";
import { getAccounts } from "@misterpea/schwab-node/account";
import { SchwabAuth } from "@misterpea/schwab-node/oauth/schwabAuth";
import { resolveSchwabPaths } from "@misterpea/schwab-node/oauth/paths";
import { EncryptedFileTokenStore } from "@misterpea/schwab-node/oauth/tokenStore";
import { createSubscriber, listen } from "@misterpea/schwab-node/streaming/zmq";Legacy import routes
The package now uses kebab-case namespace paths such as market-data.
Legacy import routes still resolve for compatibility, but they emit a one-time DeprecationWarning telling callers which path to move to.
| Legacy import | Use instead |
|---|---|
@misterpea/schwab-node/marketData/quotes |
@misterpea/schwab-node/market-data |
@misterpea/schwab-node/marketData/highLevelData |
@misterpea/schwab-node/market-data |
@misterpea/schwab-node/marketData/derivatives |
@misterpea/schwab-node/derivatives |
@misterpea/schwab-node/marketData/request |
@misterpea/schwab-node/scripts/request |
Compatibility notes:
marketData/quoteskeeps the old array-wrapped quote and price-history envelope.marketData/highLevelDatakeeps the old movers envelope of{ screeners: [...] }[].marketData/derivativeskeeps the old array-wrapped option-chain shape and maps ATM output back today_of_week.
Found a bug or have a feature request? Please open an issue using the Issue Form: https://github.com/MisterPea/schwab-node/issues/new/choose
Planned features currently in development:
- Expand coverage for remaining account endpoints
- Enhance historical data replay support
- Improve ZeroMQ adapter routing and message filtering
- Refine and expand documentation
AI tooling (OpenAI Codex) was used as a development assistant for:
- Identifying potential bugs and edge cases
- Strengthening the authentication flow
- Assisting with test development and validation
All core architecture, implementation, and final code decisions were written and reviewed by the project author.