Free learning tools Test your finance and trading knowledge before you build, buy, or automate.
Charles Schwab API · Python

Charles Schwab API Python Tutorial 2026

A source-code-level walkthrough of the official Schwab APIs through schwabdev 4.0.0 and schwab-py 1.5.1: OAuth, token lifetimes, quotes, option chains, orders, WebSocket streaming and the failure modes that break production bots.

Charles Schwab does not publish an official Python SDK. It publishes the REST API, the streaming protocol and the OAuth rules; the Python layer is community-maintained. That is not a detail to skip. The two wrappers make different choices about token storage, refresh timing, type validation, order construction and streaming reconnect behavior. Choosing the wrong one costs days of debugging. This guide is built from the actual source of both projects, not from a generic quick-start.

1. What the Schwab API covers

The API is split into two surfaces:

SurfaceBase pathRepresentative endpoints
Trader API/trader/v1Account numbers, balances, positions, orders, preview, replace, cancel, transactions, user preferences and streamer credentials.
Market Data API/marketdata/v1Quotes, option chains, expiration chains, price history, movers, market hours and instruments.
OAuth 2.0/v1/oauthAuthorization code, token exchange, refresh and rotation.

The practical ceilings are 120 market-data calls per minute and 60 trading/account calls per minute. Option chains are expensive: one request can return a large payload, so filter by expiration, strike count and contract type before you loop over symbols.

API access is free for approved individual developer applications, but approval is not instant. You need a Schwab account, a developer app and an exact OAuth callback URL. Market-data entitlements and trading permissions still depend on the underlying account.

2. Prerequisites: app, keys and callback URL

Before writing Python, create the app in the Schwab Developer Portal:

  1. Create a developer account and request the Trader API — Individual product.
  2. Create an application and set the callback URL. For local development use https://127.0.0.1:8182 — the exact string, including scheme and port, must match the portal.
  3. Wait for approval, then copy the App Key (consumer ID) and App Secret.
  4. Store both in environment variables, never in source code or Git.

The callback URL is part of the security model. Both wrappers start a local HTTPS server on that port to capture the authorization code. Anyone who can reach that callback can steal the token, so bind to 127.0.0.1 and keep the port closed at the firewall. A detailed portal walkthrough is in the setup and OAuth guide.

3. Install the wrappers

python -m venv .venv
# Windows: .venv\Scripts\activate
# macOS/Linux: source .venv/bin/activate

pip install schwabdev==4.0.0 schwab-py==1.5.1 python-dotenv

schwabdev requires Python 3.11+ and depends on requests, aiohttp, websockets, cryptography and tzdata. schwab-py requires Python 3.10+ and uses authlib, httpx, flask, websockets, psutil and python-dateutil. Pin versions in production; both projects are actively changing.

4. Authentication: two valid designs

Both libraries implement the same OAuth 2.0 authorization-code flow, but they persist and refresh tokens differently.

4.1 schwabdev: SQLite token database + automatic refresh

import os
import schwabdev

client = schwabdev.Client(
    os.environ["SCHWAB_APP_KEY"],
    os.environ["SCHWAB_APP_SECRET"],
    os.environ["SCHWAB_CALLBACK_URL"],   # e.g. https://127.0.0.1:8182
)

accounts = client.linked_accounts().json()
account_hash = accounts[0]["hashValue"]

The default token store is ~/.schwabdev/tokens.db, a SQLite database. The access token is treated as a 30-minute credential and the refresh token as a seven-day credential. update_tokens() refreshes the access token when it is within roughly 61 seconds of expiry; the refresh token is renewed through the authorization flow when it is within roughly 60 minutes of its seven-day deadline. The client refreshes the session Authorization header after every token update.

For encryption at rest, generate a Fernet key and pass it as encryption=:

from cryptography.fernet import Fernet

key = Fernet.generate_key().decode()
client = schwabdev.Client(
    os.environ["SCHWAB_APP_KEY"],
    os.environ["SCHWAB_APP_SECRET"],
    os.environ["SCHWAB_CALLBACK_URL"],
    encryption=key,
)

Save the Fernet key outside the token database. Losing it means re-authenticating; committing it defeats the purpose. The library also supports ClientAsync for asyncio workloads and a background token-refresh task.

4.2 schwab-py: token file + easy_client

from schwab import auth

client = auth.easy_client(
    api_key=os.environ["SCHWAB_APP_KEY"],
    app_secret=os.environ["SCHWAB_APP_SECRET"],
    callback_url=os.environ["SCHWAB_CALLBACK_URL"],
    token_path=".tokens/schwab-token.json",
)

easy_client() loads an existing token file if present; otherwise it starts the browser login flow, captures the callback and writes the token. The token file contains metadata, not just the bearer string. The default max_token_age is 6.5 days, so a token that is close to the seven-day refresh boundary is proactively replaced.

For headless servers, generate the token once on a machine with a browser, copy the token file to the server, then use client_from_token_file(). For notebooks or environments without a local callback server, use client_from_manual_flow(). The library also exposes client_from_access_functions() for custom storage such as a database or secret manager.

Security: the callback server is a local HTTPS server. Never expose it publicly. Keep the token file permission-restricted (chmod 600), exclude it from backups that are not encrypted, and rotate the app secret if it leaks.

5. First API calls

5.1 Quotes

# schwabdev
quotes = client.quotes(["AAPL", "MSFT", "SPY"]).json()

# schwab-py
r = client.get_quotes(["AAPL", "MSFT", "SPY"])
r.raise_for_status()
quotes = r.json()

Batch symbols in one request where possible. A loop that calls one quote endpoint per symbol burns the 120-per-minute market-data budget and increases latency.

5.2 Price history

# schwabdev
history = client.price_history(
    "AAPL",
    periodType="year", period=1,
    frequencyType="daily", frequency=1,
).json()

# schwab-py
r = client.get_price_history_every_day("AAPL")
r.raise_for_status()
history = r.json()

schwab-py provides convenience methods for one-, five-, ten-, fifteen- and thirty-minute bars, plus daily and weekly bars. For custom ranges, use get_price_history() with start_datetime and end_datetime. The API returns timestamps in milliseconds; convert them before joining with other datasets.

5.3 Option chains

# schwabdev
chain = client.option_chains(
    "AAPL",
    contractType="CALL",
    strikeCount=10,
    includeUnderlyingQuote=True,
).json()

# schwab-py
r = client.get_option_chain(
    "AAPL",
    contract_type=client.Options.ContractType.CALL,
    strike_count=10,
    include_underlying_quote=True,
)
r.raise_for_status()
chain = r.json()

Always constrain the chain. A full chain on a liquid index can be enormous. Use from_date/to_date, strike_count, contract_type and strike_range. Historical option pricing is not provided by the API, so any options backtest needs an external dataset or forward-collected snapshots.

6. Orders: preview before you send

Both wrappers can send raw order payloads; schwab-py also provides typed builders. Preview first, then place, then reconcile.

# schwabdev: raw order dictionary
order = {
    "orderType": "LIMIT",
    "session": "NORMAL",
    "duration": "DAY",
    "orderStrategyType": "SINGLE",
    "price": "100.00",
    "orderLegCollection": [{
        "instruction": "BUY",
        "quantity": 1,
        "instrument": {"symbol": "AAPL", "assetType": "EQUITY"},
    }],
}
preview = client.preview_order(account_hash, order).json()
# resp = client.place_order(account_hash, order)
# order_id = resp.headers.get("location", "/").split("/")[-1]
# client.cancel_order(account_hash, order_id)
# schwab-py: typed builder
from schwab.orders.equities import equity_buy_limit

order = equity_buy_limit("AAPL", 1, 100.00)
preview = client.preview_order(account_hash, order)
preview.raise_for_status()

placed = client.place_order(account_hash, order)
placed.raise_for_status()
order_id = client.extract_order_id(placed)

Do not retry a POST /orders blindly after a timeout. The order may already exist. Query the order list, match on the client order ID or symbol/time/quantity, and only then decide whether to retry. For options, validate the exact OCC symbol and the account's options approval level before sending.

7. Streaming: WebSocket, not polling

The streamer is a separate WebSocket protocol. You first call the Trader API for streamer credentials, then log in to the socket and subscribe to services.

7.1 schwabdev Stream

import schwabdev

streamer = schwabdev.Stream(client)

def on_message(message):
    print(message)

streamer.start(on_message)
streamer.send(
    streamer.level_one_equities(
        "AAPL,MSFT",
        "0,1,2,3,4,5,6,7,8",
    )
)
# ... later
streamer.stop()

schwabdev exposes helpers for level-one equities, options, futures, futures options and forex; NYSE, NASDAQ and options books; equity/futures charts; screeners; and account activity. Commands are ADD, SUBS, UNSUBS and VIEW. Up to 500 keys can be streamed. The streamer includes reconnect/backoff logic and an optional start_auto() scheduler around market hours.

7.2 schwab-py StreamClient

import asyncio
from schwab.streaming import StreamClient

async def main():
    accounts = client.get_account_numbers().json()
    account_id = int(accounts[0]["accountNumber"])

    stream = StreamClient(client, account_id=account_id)

    async def handler(message):
        print(message)

    stream.add_level_one_equity_handler(handler)
    await stream.login()
    await stream.level_one_equity_subs(["AAPL", "MSFT"])

    while True:
        await stream.handle_message()

asyncio.run(main())

schwab-py requires a handler per service type. The handler receives parsed messages; the client exposes subscription methods such as level_one_equity_subs(), level_one_option_subs(), nyse_book_subs(), nasdaq_book_subs(), options_book_subs(), chart_equity_subs() and account_activity_subs(). You drive the receive loop yourself with handle_message(), which makes it easy to integrate into an existing async service.

Streaming is not the source of truth. Sockets drop, messages arrive out of order and subscriptions can fail silently. Reconcile positions and orders through the REST API on reconnect and before any risk decision.

8. Rate limits, errors and retries

StatusMeaningCorrect action
401Missing/expired access tokenRefresh the token; recreate the client if the refresh token is invalid.
403App or account not entitledCheck app approval, product access and account permissions. Do not retry in a loop.
429Rate limitBack off with jitter; reduce request fan-out; cache quotes and instrument metadata.
400Invalid order/parameterValidate the payload and preview the order; fix the request, do not retry unchanged.
5xxUpstream failureRetry idempotent GETs with capped exponential backoff; reconcile order POSTs before retrying.

schwabdev configures a requests retry policy for 429 and 500/502/503/504 and refreshes tokens before requests. schwab-py returns raw httpx responses; you decide when to call raise_for_status() and how to retry. Neither library can make an unsafe order retry safe: that is your reconciliation logic.

9. Production architecture that survives

Minimum viable production checklist

  • Secrets: environment variables or a secret manager; never commit keys or token files.
  • Tokens: encrypted SQLite (schwabdev) or permission-restricted JSON (schwab-py); monitor refresh-token age.
  • Time: store UTC; convert Schwab millisecond timestamps explicitly; schedule market hours in the exchange timezone.
  • Orders: preview, place, persist the order ID, reconcile by order ID and positions; never infer fills from a socket message alone.
  • Risk: per-symbol exposure, daily loss cap, max open orders, kill switch and a separate process for the streamer.
  • Observability: structured logs, request IDs, 429 counters, stream reconnect counters and a daily reconciliation report.
  • Testing: replay recorded quotes, simulate fills, and use a dedicated small account. The API has no paper-trading mode.

10. FAQ

Is there an official Schwab Python SDK?

No. Schwab documents the API; schwabdev and schwab-py are independent MIT-licensed wrappers. Neither is affiliated with Schwab.

Which wrapper is faster to production?

schwabdev, because token management, retries and the streamer are built in. schwab-py is better when you want explicit order builders and typed enums.

Can I stream options and futures?

Yes. Both wrappers expose level-one options, futures, futures options and forex services, plus book depth for supported venues. Entitlements still apply.

Why does my token expire after a few days?

The refresh token has a seven-day lifecycle. If the process is offline when it expires, you must repeat the authorization flow. Keep the refresh token fresh and monitor its age.

Can I run this on a VPS?

Yes, but generate the token where a browser is available, copy the token securely, and run the callback server only on localhost. Use a process supervisor and a persistent token path.

Schwab API Python series: Hub | Full tutorial | Setup & OAuth | schwabdev | schwab-py | Comparison

Need the Execution Layer Built for You?

We build Schwab, Interactive Brokers and MT5 Python systems with OAuth, order reconciliation, risk controls, streaming and deployment. Source code and documentation included.

Discuss Your Project

Sources and further reading: schwabdev on GitHub, schwab-py on GitHub, Schwab Developer Portal. Versions referenced: schwabdev 4.0.0, schwab-py 1.5.1. Both libraries are unofficial and not affiliated with Charles Schwab. Nothing here is financial advice.

🚀 Invite friends — Earn $5 You both get $5 credit on Go Ad · opencode.ai AI-Powered Coding Agent — Try Free Build apps, fix bugs & ship faster with opencode. Get $5 free credit when you join. × Ad · quo.com QuoPhone — $20 Visa Gift Card Free Sign up to Quo, subscribe 3 months, get a $20 Visa gift card. Atif's referral gift for you. ×