SnapTrade Integration Documentation (2025 Architecture)
Overview
This document explains the SnapTrade integration in the Portfolio Tracker app. It is designed for reliability, scalability, and a great user experience. It covers the full flow: from user connection, through initial sync, to ongoing updates via webhooks. It also explains how sync status is tracked and how the frontend stays in sync with backend data.
Positions lifecycle: how stored positions are kept in sync with SnapTrade (soft-delete reconciliation, the trust gate, ghost cleanup) is documented separately in SNAPTRADE-POSITIONS-RECONCILIATION.md.
Table of Contents
- Architecture Overview
- 2025 QStash Daily Snapshot Architecture
- Database Schema
- API Routes & Backend Logic
- Frontend Integration
- Error Handling & Troubleshooting
- Best Practices & Local Development
- Example Flows & Code Snippets
Architecture Overview
High-Level Flow
- User initiates connection from the Accounts page.
- Backend generates a SnapTrade auth URL and redirects the user to SnapTrade’s portal.
- User completes authentication with their broker (e.g., Fidelity, Schwab).
- SnapTrade redirects back to our callback endpoint.
- Backend processes the connection:
- Fetches all connections and accounts for the user.
- Inserts/updates records in
snaptrade_institutionsandsnaptrade_accounts. - Immediately fetches and upserts balances and positions for each account (initial sync).
- Sets
sync_statusfor each account (syncing,success,error).
- Frontend polls or subscribes to sync status and shows a “Syncing…” state until data is ready.
- Ongoing updates: SnapTrade sends webhooks when account data changes. The backend processes these, but only if the account’s
sync_statusissuccess(to avoid race conditions). - Frontend auto-refreshes when sync completes or new data arrives.
Diagram
2025 QStash Daily Snapshot Architecture
Overview
To support scalability and reliability for daily net worth and account snapshots (including SnapTrade data), the app now uses a QStash-based fan-out pattern for daily snapshotting. This replaces the old monolithic cron job, which is now deprecated for daily snapshotting.How It Works
-
/api/cron/schedule-daily-snapshots (GET, called by Vercel Cron)
- Authenticates with
CRON_SECRET. - Fetches all non-test users.
- Queues a QStash job for each user to
/api/cron/process-daily-snapshotwith{ clerkId }in the body. - Adds a delay between jobs to avoid rate limits and backend overload.
- Authenticates with
-
/api/cron/process-daily-snapshot (POST, called by QStash)
- Verifies QStash signature.
- Runs the full daily snapshot logic for a single user, including:
- Plaid, manual, crypto, property, and SnapTrade data
- SnapTrade account, position, and balance daily snapshots
- Net worth, cash, and market value calculations
- Upserts to all relevant snapshot tables
- Sends daily summary email if enabled
Benefits
- Scalability: Handles thousands of users without Vercel function timeouts.
- Reliability: Each user’s snapshot is isolated; failures/retries are per-user.
- Observability: Logs and error handling are per-user/job.
- No more monolithic cron timeouts!
Flow Diagram
Code Snippet (Scheduling)
Note on SnapTrade Data
- SnapTrade account, position, and balance daily snapshots are now included in daily net worth and portfolio value calculations.
- The following tables are populated daily:
snaptrade_account_daily_snapshotssnaptrade_position_daily_snapshotssnaptrade_account_balance_daily_snapshots
- These are used for historical analytics, net worth, and reporting.
Deprecated: Old Monolithic Cron
- The old
/api/cron/daily-snapshotsroute is now deprecated for daily snapshotting. - Use the QStash fan-out pattern for all production snapshot jobs.
Database Schema
1. snaptrade_users
- Maps your app’s user (Clerk) to a SnapTrade user.
- Stores SnapTrade user ID and secret.
2. snaptrade_institutions
- Represents a connection between a user and a brokerage (e.g., Fidelity).
- Key columns:
clerk_id,snaptrade_user_id,snaptrade_connection_id,broker_name,status,last_synced_at. snaptrade_connection_idis always set to the real SnapTrade connection ID (never null).
3. snaptrade_accounts
- Represents individual accounts under a connection (e.g., a brokerage account, IRA, etc).
- Key columns:
clerk_id,snaptrade_user_id,snaptrade_connection_id,snaptrade_account_id,account_name,account_type,sync_status,last_synced_at,last_sync_result. sync_statustracks the state:syncing,success,error.- Derived balance (CFO-6245): some brokers (e.g. Empower 401k) return a NULL
balance.totalwhile positions sync fine. When that happens on a trusted sync, the holdings sync derivesbalance_usdfrom active non-cash-equivalent positions + USD cash and labels it viabalance_is_derived/balance_derived_at/balance_derivation_source(positions_sum|cash_sum). A null total is never written as X”); a both-null balance renders as unavailable (”—”). Complete-picture rule: a derivation is only written when BOTH halves are known this sync — the active positions read succeeded AND fresh cash is available (computed from this sync’s balance response, never a stale re-read). If either is unknown (positions readback failed/capped, or the balance fetch failed), the derivation is skipped and the prior value/label are PRESERVED — a positions-only or cash-only figure would silently understate the full-account total. Likewise if a later sync is untrusted/fails; the stalebalance_derived_atis the staleness signal. Support note: per-account displays show ”—” for unknown balances, but the SQL net-worth aggregates COALESCE unknown to 0 — so “my account shows — but my net worth didn’t drop” is expected behavior, not a bug (an unknown account simply contributes nothing to the total).
4. snaptrade_account_balances
- Stores multi-currency balances for each account.
- Key columns:
account_id(referencessnaptrade_accounts),currency_code,balance,available_balance,last_updated.
5. snaptrade_account_positions
- Stores holdings/positions for each account.
- Key columns:
account_id,symbol,units,price,open_pnl,average_purchase_price,last_updated.
6. snaptrade_webhook_events
- Logs all incoming webhooks and their processing status.
- Useful for debugging and replaying missed events.
API Routes & Backend Logic
1. /api/snaptrade (Connection Initialization)
- Generates a SnapTrade auth URL for the user.
- Handles local vs. production URLs (uses ngrok for local dev).
- Redirects user to SnapTrade’s portal.
2. /api/snaptrade/callback (OAuth Callback)
- Handles the redirect from SnapTrade after user authentication.
- Verifies parameters and redirects user to the accounts page.
- (Most of the heavy lifting is done in the next step.)
3. /api/snaptrade/process-connection (Initial Sync)
- Core of the integration.
- Fetches all connections and accounts for the user from SnapTrade.
- Upserts connections into
snaptrade_institutions(using the real SnapTrade connection ID). - Upserts accounts into
snaptrade_accounts. - For each account:
- Sets
sync_statustosyncingat the start. - Fetches balances and upserts into
snaptrade_account_balances. - Fetches positions and upserts into
snaptrade_account_positions. - On success, sets
sync_statustosuccessand updateslast_synced_at. - On error, sets
sync_statustoerrorand logs the error inlast_sync_result.
- Sets
- Returns a summary to the frontend.
4. /api/snaptrade/webhook (Ongoing Sync)
- Handles webhooks from SnapTrade (e.g., holdings updated, transactions, etc).
- Before processing, checks the account’s
sync_status:- If not
success(e.g., stillsyncing), skips processing and logs the event for retry. - If
success, fetches latest balances/positions and upserts as above.
- If not
- Logs all events in
snaptrade_webhook_events.
5. /api/snaptrade/refresh (Manual/On-Demand Refresh)
- (Optional) Allows the user to manually trigger a refresh of their SnapTrade data.
Frontend Integration
1. User Experience
- User clicks “Connect SnapTrade” on the Accounts page.
- Sees a “Syncing…” state while initial sync is in progress.
- Accounts, balances, and holdings appear as soon as sync completes.
- If an error occurs, user sees a clear error and can retry.
- Ongoing updates (via webhooks) are reflected in real time or on page refresh.
2. Polling/Realtime
- The frontend polls or subscribes to the
sync_statusof each SnapTrade account. - When
sync_statuschanges tosuccess, the UI auto-refreshes. - If
sync_statusiserror, the UI shows an error and offers a retry.
3. Manual Refresh
- User can trigger a manual refresh (calls
/api/snaptrade/refresh).
Error Handling & Troubleshooting
- All errors are logged in the backend and in
snaptrade_webhook_events. - Sync status is always updated (
syncing,success,error) so the frontend can show the right state. - Common issues:
- If accounts or balances are missing, check logs and the
sync_statuscolumn. - If webhooks are skipped, check if initial sync is still in progress.
- If
snaptrade_connection_idis null, ensure you are always usingconnection.idfrom SnapTrade.
- If accounts or balances are missing, check logs and the
- See the troubleshooting section at the end of this doc for more tips.
Best Practices & Local Development
- Use ngrok for local development so SnapTrade can reach your local server.
- Set
NEXT_PUBLIC_NGROK_URLin your.env.localto your ngrok URL. - Always use the real SnapTrade connection ID (
connection.id) forsnaptrade_connection_id. - Use upsert with
onConflictfor all balance and position inserts to avoid duplicates. - Keep sync logic idempotent and robust to retries.
- Use clear, user-friendly error messages in the frontend.
- Log all webhook events for debugging and replay.
Example Flows & Code Snippets
1. Initial Sync (process-connection)
2. Webhook Processing
3. Frontend Polling Example
Conclusion
This architecture ensures:- Users see their full account data immediately after connecting.
- Ongoing updates are reliable and race-condition free.
- Errors are visible and actionable for both users and engineers.
- The system is robust, scalable, and easy to extend.