Charles Schwab API Trading with LumiBot¶
Lumibot integrates directly with Charles Schwab’s Trader API for equities and options. Everything you need is built-in; no external wrapper is required.
What BotSpot MCP adds
Use Claude, ChatGPT, Codex, Cursor, VS Code or another MCP-compatible assistant? Connect the free BotSpot MCP for:
Skills: build LumiBot strategies.
Free data: news, economics and SEC filings.
Backtests: run tests and read charts.
Cloud hosting: low-cost scheduled bots.
Community: share bots and discuss trades.
Marketplace: discover and publish strategies.
Broker linking: connect accounts through BotSpot.
Order lifecycle and rejection details¶
Broker order observations preserve Schwab’s rejection description, including observations recovered by a direct order lookup and errors deferred during synchronization. A rejected cancellation is reconciled against the broker’s order state: an already-filled order produces fill evidence, while an already-canceled order produces cancellation evidence. Other cancellation errors remain visible. Partial fills reported with a cancellation are processed before the cancellation callback.
Repeated immediate account-stream disconnects use increasing retry delays, capped at 30 seconds. A login alone does not reset the retry delay; a connection must remain healthy for at least 60 seconds. This reduces reconnect storms but does not remove provider connection or authentication restrictions.
Go directly to Schwab prerequisites, credentials, first-time OAuth login, or token renewal.
If you want the managed path, BotSpot can help you connect supported brokers through the website, run paper or live Lumibot strategies, and monitor logs, account state, alerts, audit history, and kill-switch controls without maintaining your own deployment server.
Cash activity history¶
broker.get_cash_events(since=aware_datetime, limit=5000) reads recent
transactions through the existing authenticated Schwab client. Omitting
since requests the most recent 60 days as a bounded default. Explicit older
starts use contiguous windows of at most 365 days, within the official endpoint’s
one-year request maximum. That maximum does not establish historical retention.
Dates require a timezone. The method does not start a strategy or place orders.
The query selects cash, journal and dividend/interest categories. Security
receive/deliver activity is outside this cash API; its zero cash amount does
not establish the value of an in-kind transfer. This method alone cannot
establish complete external-capital coverage for such an account.
ACH, cash and wire movements retain their signed amount and stable provider
activity ID. Dividends and interest remain investment income; trade proceeds
and embedded commissions are not external capital. Exact Schwab bank-sweep and
cash/margin subaccount-transfer labels remain non-external journals. The separate
FED INC TAX WHLD IRA entry is external distribution withholding, not an
investment loss. Unknown journals,
in-kind transfers, currency conversions, explicit correction/unknown activity
actions and non-final transaction statuses
raise an explicit error rather than fabricating a zero cash flow.
Saturated responses and timed-out large windows are divided into overlapping date windows and deduplicated by activity ID. Provider failures, conflicting observations and limits that would discard events fail explicitly. Currency must be present in the provider cash instruments. A returned list, including an empty list, is event evidence; it does not independently certify historical coverage or server persistence.
Prerequisites¶
A Schwab brokerage account that is approved for API access (apply once in the Schwab Developer Portal).
A Schwab App Key (sometimes called Consumer Key) generated inside your Developer Portal application.
The brokerage account number you want the bot to trade in.
A callback URL (HTTPS) you entered when creating the app. For local testing just use
https://127.0.0.1:8182.
Environment variables¶
Note
Easy Setup with .env File
LumiBot automatically loads your API credentials from a .env file! Simply create a .env file in the same folder as your trading strategy and add your Schwab credentials. LumiBot will automatically detect and use these credentials - no additional configuration required.
Example .env file:
# Schwab Configuration
SCHWAB_ACCOUNT_NUMBER=your_account_number
SCHWAB_TOKEN=your_token_if_needed
TRADING_BROKER=schwab
That’s it! LumiBot handles the rest automatically.
Set the following before running your strategy (.env file, Render secret, Replit secret, Docker env, etc.):
Variable |
Purpose |
|---|---|
|
The brokerage account to trade. |
|
Base-64 payload pasted from the first OAuth login screen – only needed for headless deploys. |
|
Force Lumibot to select Schwab ( |
First-time login¶
Desktop – run your bot, a browser pops up, log in, click Allow. A
token.jsonfile is written next to your strategy. Restart and you’re done.Headless / Render / Replit – the console prints a one-time URL. Open it on any device, log in, copy the payload string that appears, set it as
SCHWAB_TOKENand restart.
Token life-cycle¶
Access token ≈ 30 min; refresh token ≈ 7 days.
Lumibot automatically refreshes the token every ~25 min and rewrites
token.json.Refresh tokens expire seven days after creation, or earlier if authorization is revoked. Access-token refresh does not guarantee an indefinitely renewable login.
On expiry or revocation, complete the normal browser authorization again.
Login and refresh save the token with mode
600through an atomic file replacement. Failed input, metadata preparation or persistence preserves the existing file. A storage/validation failure reports an error; fix storage or explicitly reauthorize instead of deleting credentials automatically.SDK startup and account-discovery failures, including HTTP 401, also retain the saved file. An unusable account hash cannot mark the broker ready. A failed initialization stops its proactive refresh task; expiry or revocation still requires normal reauthorization.
Supported functionality¶
Equities and ETF trading (market, limit, stop, stop-limit).
Single-leg options (buy/sell, open/close).
OTO / one-triggers-other orders for single stock/ETF and option parent/child orders (experimental; live broker validation recommended before production use).
OCO / one-cancels-other and bracket orders for single stock/ETF and option orders (experimental; live broker validation recommended before production use).
Streaming quotes for equities/options.
Historical bars for equities, ETFs, and option contracts (OCC symbol). Futures candle history is not requested. Crypto is not on this API.
Multi-leg option spreads and futures trades are not yet implemented.
Deposits, withdrawals and account transfers¶
Schwab cash history uses the existing authenticated brokerage client. Signed cash amounts and stable provider transaction IDs distinguish deposits, withdrawals, internal journals, income, fees and adjustments. The adapter audits non-trade transaction categories, uses bounded requests within Schwab’s one-year request maximum and subdivides saturated or timed-out windows. Trade proceeds are not external funding.
Use get_cash_event_sync(since=..., limit=...) when completeness matters.
It preserves known cash while reporting unresolved journals, corrections or
in-kind transfers as partial. It does not infer the capital value of securities
from cost basis or a zero cash amount. A failed provider read is unavailable,
not a complete empty history. The default 60-day lookback does not certify
all-time retention.
Audited windows match the SDK’s whole-second precision. Requests overlap each inclusive start by one second within the one-year request bound; normalized events are filtered to the declared start-inclusive/end-exclusive window. See Cash Accounting for funding receipts, batch retries and one-shot account snapshots. These methods retrieve/report history and do not initiate cash or security transfers.
Order and position freshness¶
In live trading, LumiBot refreshes broker state before returning from:
self.get_order(order_id)self.get_orders(...)self.get_position(asset)self.get_positions()self.get_cash()self.get_portfolio_value()
After submitting an order, use direct order lookup when checking that exact order:
submitted = self.submit_order(order)
latest = self.get_order(submitted.identifier)
Schwab can expose a just-submitted order through direct lookup before it appears
in the broad account order list. If your strategy must use self.get_orders(...)
immediately after submitting, add a short LumiBot sleep first:
submitted = self.submit_order(order)
self.sleep(1, process_pending_orders=True)
active_orders = self.get_orders(statuses=Order.ACTIVE_STATUSES)
Use enum status filters for active/open order checks:
active_orders = self.get_orders(statuses=Order.ACTIVE_STATUSES)
Do not use raw string status filters. For duplicate-order guards, filter by active status and by the exact asset or option contract.
Simple stock and single-leg option self.modify_order(...) calls use Schwab’s
replace-order endpoint. Schwab returns a new broker order id for the replacement;
LumiBot updates the order object’s identifier and keeps the old id in
previous_identifiers. For timeout logic where the intended behavior is to
remove the order, prefer self.cancel_order(order) instead of modifying the
order into a different price. A successful cancel response is acceptance, not
terminal confirmation: the order remains active with CANCELLING status
until Schwab later reports CANCELED, FILLED, EXPIRED, or a
rejection/error. Do not put an immediate direct order read in front of the
cancellation deadline.
Schwab account history can include rows that are not ordinary strategy orders,
such as mutual funds, sweep/cash-equivalent records, bonds, option exercise
records, or future Schwab asset/order/status values. LumiBot preserves
representable unknown Schwab records as Asset.AssetType.UNKNOWN,
Order.OrderType.UNKNOWN, Order.OrderSide.UNKNOWN, or
Order.OrderStatus.UNKNOWN instead of failing the whole broker refresh.
If Schwab cannot return fresh cash or portfolio value, self.get_cash() and
self.get_portfolio_value() return None and leave cached values unchanged.
Do not treat None as zero.
Fast cancellation and request budgets¶
For rejected or expired orders, the strategy’s error callback receives the original Schwab status and its rejection description when the broker supplies one. If no description is available, the error reports the status alone; LumiBot does not infer a rejection reason. Repeated terminal snapshots do not produce duplicate error callbacks.
Separate three measurements when implementing a cancel-after deadline:
the local time at which the strategy dispatches
cancel_order;the HTTP response time for the cancel request;
the later broker-terminal outcome such as
CANCELEDorFILLED.
The strategy controls the first measurement. It cannot guarantee the other two at an exact deadline.
Use a configurable cancel_after_seconds policy and an absolute
time.monotonic() deadline. Do not use one fixed timeout for every strategy.
Finish expensive chain, quote, and liquidity work before submission; then
process local pending order events while waiting for the deadline:
deadline = time.monotonic() + cancel_after_seconds
while time.monotonic() < deadline and order.is_active():
remaining = deadline - time.monotonic()
self.sleep(min(0.05, remaining), process_pending_orders=True)
if order.is_active():
self.cancel_order(order)
The short self.sleep calls above process local queued events. They are not
broker polls. Avoid calling self.get_order every fraction of a second or
placing a broker read immediately before the deadline. Use a bounded exact-order
read later for a missed callback, restart/reconnect, or ambiguous cancel result.
on_filled_order is the fast path for a fill and already includes the filled
order. on_canceled_order reports terminal cancellation; it does not initiate
the cancel. cancel_order may return before the queued
on_canceled_order callback runs. Route callbacks and later reconciliation
through one idempotent reducer keyed by the broker order identifier or a stable
causal group.
These on_* methods are lifecycle callback methods: LumiBot invokes them
after broker observations. on_partially_filled_order(position, order, price,
quantity, multiplier) receives the newly observed fill delta in quantity;
it is not cumulative across callbacks. A later on_filled_order receives the
remaining fill delta and must share the same idempotency state.
LumiBot uses Schwab account-activity WebSocket messages to wake exact reads of locally tracked active orders. REST snapshots and stream-triggered observations feed one serialized transition reducer, including after login or reconnect. A 30-second broad history poll remains as a healing fallback. This is deliberately not one-second broad polling: one-second polling multiplies request pressure and does not remove fill/cancel races.
The REST and account-activity clients share the same refreshable OAuth token metadata. A token refresh updates the shared token object in place, so a later stream login or reconnect uses the current access token instead of maintaining a second credential owner.
Scope blocking to the strategy’s actual risk invariant. A cancel-pending order must block a conflicting replacement for the same exposure. Independent symbols may continue when capital and risk policy permit. Unknown broker state is not terminal, but it does not automatically require a strategy-wide freeze.
Schwab developer applications have an application-level order limit for make, cancel, and replace requests per minute. Treat throttling as an aggregate broker-call budget across market data, order lists, exact reads, submits, cancels, and replaces. Do not infer a universal requests-per-second guarantee. See the schwab-py order-limit documentation and def on_canceled_order.
When Schwab returns HTTP 429, LumiBot honors Retry-After when present and
otherwise applies bounded exponential backoff with jitter for that endpoint
family. A throttled read returns no new observation and never converts the
tracked order to a terminal status.
An authorized local sample of 16 successful cancel HTTP responses ranged from 228 ms to 444 ms, with a 302.5 ms median and 444 ms 95th percentile. This small sample is not a Schwab service-level guarantee and does not measure terminal callback visibility. Do not choose a strategy deadline by multiplying these observations.
Example .env¶
TRADING_BROKER=schwab
SCHWAB_ACCOUNT_NUMBER=12345678
# optional if deploying headless
SCHWAB_TOKEN=YOUR_TOKEN
Example strategy snippet¶
from lumibot.entities import Asset
last = self.get_last_price("SPY")
chains = self.get_chains(Asset("SPY"))
first_expiry = chains.expirations("CALL")[0]
atm_strike = min(chains.strikes(first_expiry), key=lambda s: abs(s-last))
contract = Asset(
symbol="SPY",
asset_type=Asset.AssetType.OPTION,
expiration=first_expiry,
strike=atm_strike,
right=Asset.OptionRight.CALL,
)
order = self.create_order(contract, 1, side="buy")
self.submit_order(order)
Troubleshooting¶
401/400 errors at login usually mean your callback URL does not match the value in the Developer Portal exactly.
Keep
token.jsonout of version control.Schwab’s API still evolves; join the Lumibot Discord for the latest community fixes.
Note
Schwab API access requires a developer account and application approval. You must apply for API access and set up your app in the Schwab Developer Portal.
API Credentials¶
To use Schwab with Lumibot, you need to set the following environment variables in your .env file:
Variable |
Description |
Example |
|---|---|---|
SCHWAB_API_KEY |
(old name) – use `SCHWAB_APP_KEY` instead. Back-compat supported but new projects should switch. |
abc123xyz |
SCHWAB_APP_SECRET |
Your Schwab API secret (Consumer Secret). |
supersecret |
SCHWAB_ACCOUNT_NUMBER |
Your Schwab brokerage account number. |
12345678 |
SCHWAB_BACKEND_CALLBACK_URL |
The exact OAuth2 callback URL that you registered in the Developer Portal. Defaults to https://127.0.0.1:8182 for local flows. |
https://yourdomain.com/callback |
TRADING_BROKER |
(Optional) Set to schwab to force Schwab as the broker. |
schwab |
SCHWAB_TOKEN (optional) |
Base64url payload string returned by the first OAuth login. Use it when running in head-less environments (Render, Replit, Docker) so the bot can bootstrap itself without an interactive prompt. |
<big-string> |
Important
SCHWAB_TOKEN imports a login payload into the token file when supplied.
Remove an old bootstrap payload from subsequent starts so it cannot overwrite
a refreshed file. Automatic access-token refresh still requires an unexpired,
authorized refresh token and the matching app configuration.
Token Life-cycle & Auto-refresh¶
Access-token ≈ 30 min, refresh-token ≈ 7 days (per Schwab policy).
Lumibot configures an OAuth2Session with
auto_refresh_urlso that tokens refresh themselves quietly in the background every ~25 min.The refreshed access token is written back to
token.json. This does not establish a rolling seven-day authorization: the refresh token expires seven days after creation or can be revoked earlier.In managed environments where a trusted parent process owns broker OAuth refresh, set
LUMIBOT_OAUTH_REFRESH_MODE=external. In that mode LumiBot does not call the Schwab OAuth refresh endpoint; it reloads an access-token-only token file when the parent atomically replaces it. Refresh-token fields are stripped from LumiBot’s child broker state in this mode.Repeat the normal browser authorization when the refresh token expires or is revoked, even if the service has remained online.
Creating an App & Getting Keys¶
Register on the Schwab Developer Portal.
Go to Dashboard → Apps → Create App.
Enter an app name and a Callback URL (must be HTTPS, ≤ 256 chars, matches exactly).
Request the Trader API product, accept terms, and submit.
Wait for manual approval (typically 1–3 business days).
Once approved, copy your API Key (Consumer Key) and API Secret from the app details.
OAuth2 Authentication Flow¶
Schwab uses OAuth2 for authentication. The first time you run your strategy, a browser window will open for you to log in and approve access. A token.json file will be created in your strategy directory (or at SCHWAB_TOKEN_PATH if set).
Access tokens last 30 minutes; refresh tokens last 7 days.
The schwab-py library will auto-refresh tokens as needed.
If running on a server, run the login flow once locally and copy token.json to the server.
For headless/cloud environments, use the CLI/manual login helper (schwab.auth.client_from_manual_flow), which prints a URL to paste into any browser.
Keep token.json secure and out of version control.
If you delete or move token.json, you will need to re-authorize.
Warning
If your refresh token expires (after 7 days without re-auth), you must repeat the browser login flow.
First-time Schwab login (cloud or local)
Cloud (Replit, Render, etc.): Deploy the bot and watch the logs for a green line: Open https://…/schwab-login in your browser Click, sign in, hit Allow, wait for “✅ Schwab token saved”, then restart the bot. That’s it—no weekly re-login as long as the bot stays active.
Local laptop: Deploy the bot and Lumibot opens a browser window automatically (same as before). Complete the login and you’re set.
As long as your bot checks Schwab at least once per day, the token auto-refreshes and you will not be asked to log in again. If the service is stopped for 7+ days, redeploy and repeat the link.
(Optional) override the callback route with SCHWAB_REDIRECT_URI=https://YOUR_DOMAIN/schwab-login.
Sandbox vs Production¶
Schwab offers a Sandbox environment for safe testing with synthetic accounts and data.
Enable Sandbox when creating your app, or promote your app later in the Developer Portal.
Use the same credentials; only the API base URL changes.
Use separate apps for production and sandbox to avoid confusion.
Supported Assets & Order Types¶
Asset |
Market |
Limit |
Stop |
Stop-Limit |
Advanced |
|---|---|---|---|---|---|
Stocks/ETFs |
✔ |
✔ |
✔ |
✔ |
OTO/OCO/bracket (experimental) |
Options |
✔ (buy/sell, open/close) |
✔ |
— |
— |
OTO/OCO/bracket (experimental) |
Futures |
✖ (quotes only) |
✖ |
✖ |
✖ |
✖ |
Crypto |
✖ |
✖ |
✖ |
✖ |
✖ |
Multi-leg/spread options are not yet implemented in Lumibot.
Schwab OTO, OCO, and bracket orders use Schwab’s trigger and one-cancels-other support and should be live-tested with the target account and order shape before relying on them in production.
Futures trading is not supported; only streaming quotes are available.
Market Data¶
Real-time quotes, last price, and option chains work for equities and options. A live read on 2026-09-22 returned SPY quotes around 774 and an SPY 775 call quote around 0.10. Current quotes include bid and ask. Price-history candles do not.
Daily candles for SPY went back through a 20-year request (2006-09-27 to 2026-09-22, 5027 bars). The every-day helper, which asks for twenty years of daily bars, returned 8469 bars from 1993-01-29 through 2026-09-22. One-minute bars capped at 2026-08-07 even when 90 days were requested. Thirty-minute bars for a one-year request started 2026-01-05. Schwab has no 60-minute frequency and no second bars. LumiBot’s hour timestep asks for 30-minute candles and says so. A second timestep returns no bars.
Price-history candles are last-trade OHLC plus volume and datetime. A live read of SPY stock daily bars and of an SPY option daily series returned only those keys. There is no historical bid or ask on the candle. Days with no trade come back as gaps. Live
get_quotestill returns bid and ask. LumiBot does not invent quote bars to fill those gaps.Option quotes, last price, chains, and candles work. History is the life of that contract, not a fixed bar cap and not the years requested. The request has to use the OCC symbol. The SPY 775 call expiring 2026-09-22 had 11 daily bars from 2026-09-08. The SPY 775 call expiring 2027-09-17 quoted at 68.31 (bid 67.75, ask 69.63). A 30-day daily request returned 20 bars from 2026-08-25. One-year, two-year, and five-year daily requests all stopped at 95 bars from 2026-03-26. One-minute bars existed and were thin: 227 bars back to 2026-08-06. The Jan 2029 775 call quoted at 126.11 and had 3 daily bars from 2026-09-18.
A Tesla check on the same day showed less history, not more. The TSLA 380 call expiring 2027-09-17 quoted at bid 76.6, ask 77.3, last 77.14. A 30-day daily request returned 21 bars from 2026-08-24. One-year, two-year, and five-year daily requests all stopped at 72 bars from 2026-05-19. One-minute bars over 48 days returned 194 bars from 2026-08-06. The TSLA 380 call expiring 2029-01-19 quoted at bid 120.0, ask 126.0, last 124.58, and had 5 daily bars from 2026-09-16 plus 34 one-minute bars from the same day. The SPY 95-bar stop is that contract’s listing, not an API limit.
Equity tickers stay equity tickers.
Asset("BTC")andAsset("ETH")still requestBTCandETH, and those can be priced and traded as stocks. A live read on 2026-09-22 showed tickerBTCis the Grayscale Bitcoin Mini Trust ETF (last price 38.265, asset type EQUITY) and tickerETHis the Grayscale Ethereum Staking Mini ETF (last price 26.355).DOGEandSOLhad no instrument.LTCandBCHare unrelated stocks. Description search found no spot bitcoin or ethereum pair. The$BLXand$ELXindexes stopped on 2023-10-30.Only
asset_typecrypto is refused.get_historical_pricesandget_quotefor a crypto asset return no bars and do not call Schwab. Spot bitcoin cannot be quoted, charted, or bought through this API. LumiBot’s order builder rejectsasset_typecrypto before any order HTTP call. A stock order for tickerBTCis an ETF order. No order was sent.Futures quotes can stream. Futures candle history is not requested.
No extra entitlements required for individual developers.
Bulk option snapshots¶
Strategy.get_chain_full_info uses one Schwab option-chain request for the
selected expiration, returning both calls and puts with bid, ask, last, sizes,
volume, open interest, Greeks, and provider quote_time / trade_time.
This avoids reconstructing the chain with one price request per contract.
The API is unchanged; the Schwab data source supplies the efficient implementation.
underlying = self.create_asset("SPY")
# Select expiration using the strategy's existing expiry policy.
snapshot = self.get_chain_full_info(underlying, expiration)
calls = snapshot[snapshot["option_type"] == "CALL"] if not snapshot.empty else snapshot
The response is a snapshot, not a stream or a promise of fresh executable prices.
Check provider timestamps, missing bid/ask, spread and liquidity before acting.
The snapshot also retains is_delayed, non_standard, multiplier and
deliverables when supplied; a strike alone does not identify a standard
100-share contract.
Missing values remain missing; they are not replaced with zero or the current time.
Apply the strategy’s original eligibility and pricing formulas to the returned
rows. A fresh decision may require a fresh snapshot. Do not retain prices across
scans indefinitely. strike_min and strike_max filter inclusively.
HTTP failures raise ConnectionError rather than returning an apparently empty
market. An empty successful chain returns an empty DataFrame.
Rate Limits & Token Expiry¶
Schwab developer applications expose a configurable order limit: the number of make, cancel, and replace requests the app may place per minute. The application’s configured value is authoritative for that app.
Schwab may return HTTP 429 when a request budget or burst limit is exceeded. Respect
Retry-Afterwhen present; otherwise use bounded exponential backoff with jitter.Do not rely on a universal data-requests-per-minute or trade-requests-per-second value. Budget all broker endpoint families and measure the target app’s actual behavior.
Access tokens expire after 30 minutes; refresh tokens after 7 days.
Known Issues & Best Practices¶
Initial OAuth requires browser login every 7 days.
token.json must be unique per account/app.
OTO, OCO, and bracket advanced orders are experimental.
Callback URL must match exactly (including trailing slash).
Access tokens last about 30 minutes. While the process is running and
SCHWAB_APP_SECRETis set, LumiBot refreshes in the background and writes the new token back to the token file. Do not also setSCHWAB_TOKEN: every start overwrites that file with the original payload. A live check on 2026-09-22 forced the access token to look expired, and LumiBot refreshed it without another login.Secure token.json (chmod 600) and rotate secrets regularly.
Use separate apps for sandbox and production.
Attempting to place a futures order returns HTTP 400 “Unsupported instrument”.
No official docs for futures endpoints—implementation subject to change.
Example Strategy¶
You can provide your Schwab credentials in several ways: - By creating a .env file in the same directory as your strategy (recommended for local development). - By setting them as secrets in Replit, or as environment variables in cloud platforms like Render. - By exporting them as environment variables in your shell.
Example `.env` file:
# .env
TRADING_BROKER=schwab
SCHWAB_ACCOUNT_NUMBER=XXXXXXXX
Then, create your main.py (or strategy.py) file:
from lumibot.traders import Trader
from lumibot.strategies.strategy import Strategy
class MyStrategy(Strategy):
def initialize(self):
self.sleeptime = "1D"
self.symbol = "SPY"
def on_trading_iteration(self):
last = self.get_last_price(self.symbol)
self.log_message(f"Last price for {self.symbol}: {last}")
asset = self.create_asset(self.symbol)
order = self.create_order(asset, 1, "buy")
self.submit_order(order)
trader = Trader()
strategy = MyStrategy()
trader.add_strategy(strategy)
trader.run_all()
Frequently asked questions¶
Does Charles Schwab have an API for trading?¶
Yes. Schwab offers the Trader API through the Schwab Developer Portal. You need a Schwab brokerage account and an app that Schwab approves for the Trader API.
LumiBot connects to that API directly, so your Python strategy can read prices and place stock, ETF and option orders in your Schwab account.
Can I run an automated or AI trading bot on my Schwab account?¶
Yes, with LumiBot. LumiBot is an open-source Python library, not a Schwab product. You write a strategy, and LumiBot sends its orders through Schwab’s Trader API.
Strategies can use plain rules or AI agents. See Build AI Trading Agents in Python with LumiBot for AI agent strategies.
How do I get a Schwab API key?¶
Register on the Schwab Developer Portal, create an app, request the Trader API product, and enter an HTTPS callback URL. After Schwab approves the app, copy the App Key and App Secret from the app details.
Approval is manual and can take a few days. Wait until the app is fully ready before you try to log in.
What environment variables does LumiBot use for Schwab?¶
Set SCHWAB_APP_KEY, SCHWAB_APP_SECRET, SCHWAB_ACCOUNT_NUMBER and SCHWAB_BACKEND_CALLBACK_URL. SCHWAB_TOKEN is optional and only helps the first start on a server with no browser.
Add TRADING_BROKER=schwab if keys for other brokers are also set.
What is the Schwab API rate limit?¶
Each Schwab developer app has an order limit. It is the number of place, cancel and replace requests the app may send per minute, and you set it on your app.
Schwab may answer with HTTP 429 when you send too many requests. Wait for the time in Retry-After if it is given, or back off and try again. Do not assume one fixed limit for data requests.
Can I paper trade with the Schwab API in LumiBot?¶
The LumiBot Schwab broker has no paper trading setting. It trades the account in SCHWAB_ACCOUNT_NUMBER.
To try a strategy without risking money, run a backtest first. See Backtesting Trading Strategies in Python with LumiBot.
How often do I need to log in to Schwab again?¶
Access tokens last about 30 minutes, and LumiBot refreshes them for you. Refresh tokens expire 7 days after they are created, or sooner if access is revoked.
When the refresh token expires, repeat the browser login once.
What can I trade on Schwab with LumiBot?¶
Stocks, ETFs and single-leg options. Stock orders can be market, limit, stop or stop-limit. OTO, OCO and bracket orders are experimental.
Multi-leg option spreads, futures orders and crypto are not supported. The ticker BTC on Schwab is a bitcoin ETF, not spot bitcoin.
Why do I get a 400 or 401 error when I log in to Schwab?¶
This usually means your callback URL does not match the one in the Developer Portal exactly. Check every character, including any trailing slash.
For local testing, https://127.0.0.1:8182 is a common callback URL.
Support & Contact¶
Schwab Developer Portal: https://developer.schwab.com/
API Documentation: https://schwab-py.readthedocs.io/
Support: Developer Portal → Support → Create Ticket, or email api-development@schwab.com
Note
For advanced usage and troubleshooting, see the schwab-py documentation and the Lumibot source code for Schwab broker and SchwabData data source.
Important
The example above shows what our strategy did in a sandbox environment; it is not investment advice.
Warning
This integration is for educational purposes only. Consult a qualified financial advisor before trading real funds.