Connect Schwab
Link a Schwab account through the official Trader API OAuth flow. Read-only calls, encrypted tokens, a sync every minute in market hours, a connection state you can trust, and disconnect.
On this page
A Schwab connection keeps positions, balances, orders and recent trades current without exporting files. It uses Schwab's official Trader API (Individual) and OAuth, so Investi never sees your Schwab password, and it only reads.
Before you start#
Schwab's Individual Trader API is for your own brokerage account, and it requires an approved developer app registered to the account owner at developer.schwab.com. The server operator then sets four environment variables:
| Variable | Value |
|---|---|
SCHWAB_APP_KEY |
The app key from your Schwab developer app. |
SCHWAB_APP_SECRET |
The app secret. |
SCHWAB_CALLBACK_URL |
<origin>/api/schwab/callback, served over https and registered with Schwab exactly as written. |
INVESTI_ENCRYPTION_KEY |
32 random bytes, base64-encoded. Encrypts the Schwab tokens at rest. |
openssl rand -base64 32Until all four are set, the Schwab panel in Settings > Connections lists the missing variable names and offers no connect button. CSV and JSON imports give you the same features in the meantime; see Import your history.
Connecting#
- Open Settings > Connections and click Connect Schwab.
- Investi creates a random one-time state value, stores it in an HttpOnly cookie scoped to
/api/schwabfor 10 minutes, and redirects you to Schwab's authorization page. - Sign in at Schwab and approve access.
- Schwab redirects to
/api/schwab/callback. Investi checks that the returned state matches the cookie, then exchanges the authorization code for tokens at Schwab's token endpoint using your app key and secret. - The tokens are encrypted and saved, a first sync runs, and you land back on Settings with the connection marked connected.
If the state does not match (for example, the link is older than 10 minutes or started in another browser), you see "This Schwab authorization link expired or didn't start here. Start again from Settings." If you cancel at Schwab, the message from Schwab is shown instead.
What Investi reads#
After the OAuth token exchange, every call to Schwab is a GET:
| Endpoint | Used for |
|---|---|
/accounts/accountNumbers |
Your account numbers and Schwab's per-account hash, on every full run and whenever a per-account call returns 404. |
/accounts?fields=positions |
Account type, positions and current balances. |
/accounts/{hash}/orders |
Orders in every status: the last 60 days on each run, 60-day pages back 180 days (every GTC order) on full runs. |
/accounts/{hash}/transactions |
Trades (TRADE), dividends and interest (DIVIDEND_OR_INTEREST), and money in or out (ACH, wires, cash receipts and disbursements). |
/userPreference |
Streamer details, including the account activity subscription, while the app is open. |
Investi has no code path that places, replaces or cancels an order, and it never asks Schwab for a trading scope. History older than five years comes from CSV or JSON imports.
Token storage and expiry#
Tokens are encrypted with AES-256-GCM using INVESTI_ENCRYPTION_KEY. Each encryption uses a fresh random 12-byte IV, and the stored value carries the IV, the authentication tag and the ciphertext. The key must decode to exactly 32 bytes; anything else is rejected with an error rather than used.
- Access tokens are refreshed when they are within 5 minutes of expiring, and once more if Schwab answers a request with 401. The new tokens are encrypted again before they are saved.
- Refresh tokens last 7 days at Schwab. From day 6 (two days or less left), Settings and the MCP freshness block warn with the exact expiry so you can reconnect before syncing stops.
- Sign-in needed. When Schwab refuses the saved authorization (
invalid_grant), or the 7 days run out, the connection becomes REAUTH_REQUIRED with the time it happened. Every app page shows a red banner with a Reconnect link, an in-app alert fires, and nothing syncs until you reconnect. Reconnecting replaces the stored tokens and clears the state.
Warning: Changing or losing
INVESTI_ENCRYPTION_KEYmakes stored tokens unreadable. Every user with a connection has to reconnect.
Syncing#
Syncs run on a schedule for every connected account, plus whenever you ask:
| When (New York time) | What runs |
|---|---|
| Every minute, 9:30 to the close, trading days | Balances, positions, orders (last 60 days) and new transactions. |
| Every 5 minutes, 4:00 to 9:30 and the close to 20:00 | The same light run. |
| 9:00 on trading days, and 20:05 every day | A full run: fresh account hashes, every GTC order (60-day pages back 180 days), and transactions since the newest one already ingested minus 3 days. Working orders Schwab no longer lists are taken off the working list. |
| 2:00 to 6:00 a.m. Central, every day | Reconcile overnight, retrying every ten minutes until a full run succeeds. Uses America/Chicago local time, including daylight saving transitions. Runs with the browser closed. |
| While the app is open | Fills, cancels and replaces at Schwab start a sync at once (the Schwab streamer's account activity). |
| Sync now in Settings | A full run plus five yearly windows of transactions: the one-time backfill of everything Schwab keeps. |
Manual sync starts in the background and returns promptly. Settings polls its progress; balances and shares for every account are written before historical order and transaction backfills. A successful empty positions response records a fresh empty account instead of reviving its previous holdings. A broker reauthorization is still required when Schwab expires or revokes the connection.
Vercel Cron calls /api/cron/schwab on five UTC lines that cover both EST and EDT; the route checks New York time (daylight saving included, NYSE holidays and early closes for 2026 and 2027) and does nothing outside these windows. A tick outside a window, or with no Schwab connection, returns without touching the database.
Each part of a run is its own step with its own retries: up to three attempts with backoff on a network error, a 429 or a 5xx, and a fresh account hash when Schwab answers 404. Every account and user has a time budget, so one slow or failing account never holds up the others. Only one run per account owner is ever open: a second tick that arrives while a run is going says "busy" and leaves it alone, and a fill or a Sync now that arrives mid-run starts a fresh run the moment the open one finishes. A run whose server was stopped mid-way is marked as not finished by the next tick.
For each Schwab account, a sync:
- Creates the account in Investi the first time it sees it, named "Schwab margin", "Schwab cash" or "Schwab other" with the number masked to its last four digits. Schwab's account hash is stored only as a SHA-256 hash for matching, and encrypted for reuse between full runs. If Schwab issues a new hash for the same account, the account keeps its id.
- Snapshots positions for equities, ETFs and collective investments. Other asset types are skipped with a warning. Quantity is long minus short. Cost basis is the figure Schwab's own screens show: market value minus Schwab's open profit or loss (falling back to the average long price times quantity). When nothing changed since the last run, the same snapshot is re-stamped rather than copied.
- Snapshots balances: cash, margin debit (read from a negative margin balance or a negative cash balance, whichever Schwab reports), long and short market value, equity (liquidation value), buying power (Schwab's figure, or available cash on a cash account) and maintenance requirement.
- Upserts orders by Schwab order id, including the legs of OCO and trigger orders. Schwab statuses map to WORKING, PENDING_ACTIVATION, FILLED, CANCELED, EXPIRED, REJECTED or UNKNOWN; a REPLACED order is recorded as CANCELED (its replacement is its own order). FILLED, CANCELED, REPLACED, EXPIRED and REJECTED orders leave the working list on the next sync. The session (including SEAMLESS) is kept.
- Adds new fills, dividends, interest and transfers. Each is keyed by Schwab's activity id, so syncing again never duplicates it.
Note: A synced account adopts an imported account with the same last four digits, so imports and syncs share one account. Nothing is counted twice across sources: for fills, whichever source arrived first (a CSV import or the API) keeps that symbol, side and New York trade day; for dividends, interest and transfers, a row with the same kind, day and amount is skipped. Either order works: import then sync, or sync then import. Schwab journal entries (shares received or re-booked, including the batch Schwab books when thinkorswim is enabled) are left out of the ledger.
Connection state#
The state in Settings, on Portfolio, in GET /api/v1/portfolio (connection), in GET /api/v1/schwab and in every MCP freshness block is computed only from runs that finished, never from a token existing:
| State | Means |
|---|---|
| LIVE | The last good run finished under 2 minutes ago in market hours (under 15 minutes outside them). |
| DELAYED | 2 to 15 minutes ago in market hours (15 minutes to 26 hours outside them). |
| STALE | Over 15 minutes in market hours, over 26 hours outside them, or no run has finished yet. |
| FAILED | The last run errored, or did not finish. |
| REAUTH_REQUIRED | Schwab needs you to sign in again. |
A run counts as good when balances and positions came back; if another step failed it is recorded as partial and shown in the history. Settings shows the state with its reason, the last good sync per account (balances and positions, orders, fills), the last 12 runs with each endpoint's HTTP code, item count and attempts, and Sync now with its result inline.
When no good run has finished for 15 minutes during market hours, or the state is FAILED or REAUTH_REQUIRED, an in-app alert fires (at most once a day per reason) and the weekly digest's connection block says so. While the state is FAILED or REAUTH_REQUIRED, a red banner sits on every app page.
Sync now is available only from the signed-in app; API tokens get 403 session_required.
Disconnecting#
Click Disconnect in the Schwab panel. Investi deletes the stored, encrypted tokens and the connection record, so it can no longer call Schwab on your behalf. Accounts, positions, orders and trades that earlier syncs brought in stay in your workspace.
Investi does not call Schwab to revoke the grant when you disconnect. The tokens it held are gone from Investi, and the refresh token expires on Schwab's 7-day schedule.
Checking status from the API#
GET /api/v1/schwab (scope portfolio:read) returns whether Schwab is configured, which variables are missing, the connection, its sync state (as above, with the last good sync per account and any re-authorization warning) and the last 12 runs with per-endpoint status. See the REST API.