When I started building FreedomTrack — a financial independence tracking app — users entered their account balances manually. It worked, but nobody wants to log in monthly just to update their 401k balance. The obvious next step was pulling data directly from financial institutions through Plaid.
The integration turned out to be one of the more architecturally interesting pieces of the app. Plaid gives you access to balances, transactions, investment holdings, and recurring payment patterns — but how you model, sync, and present that data to users matters a lot more than the API calls themselves.
How Plaid Link Works
The entry point is Plaid Link, a drop-in UI component that handles the entire bank authentication flow. Your app never touches bank credentials — Plaid's iframe handles login, MFA challenges, and account selection. The flow looks like this:
- Your server creates a link token — this configures what products you want (transactions, investments, etc.) and what country/language to use
- The frontend opens Plaid Link with that token — the user picks their bank and logs in
- On success, Link returns a public token — a short-lived, one-time-use token
- Your server exchanges it for an access token — this is the permanent credential you'll use for all future API calls against that institution
The access token is the critical piece. It represents a persistent connection to a user's accounts at a specific institution. One token can cover multiple accounts — a user's checking, savings, and credit card at the same bank all live under one token.
Edge Function Architecture
I built the integration as a set of Supabase Edge Functions, each handling a specific concern:
plaid-create-link-token— Generates the Link token with the right product configurationplaid-exchange-token— Swaps the public token for an access token and stores the institution connectionplaid-sync-balances— Pulls current balances for all accounts under an institutionplaid-sync-transactions— Uses Plaid's sync endpoint to incrementally fetch new, modified, and removed transactionsplaid-sync-investments— Fetches investment holdings and their current valuesplaid-sync-recurring— Identifies recurring transaction patterns (subscriptions, regular bills)
Each function authenticates the request via JWT, verifies the user owns the Plaid item they're trying to sync, and handles Plaid API errors. The access tokens never leave the server — the frontend works with institution and account IDs only, and a database view strips the token column from any query results.
Data Modeling: Keeping Plaid Separate from Your Domain
This was the most important architectural decision. Plaid data lives in its own tables, connected to the app's core domain through explicit links that users control.
plaid_items (institution connections)
└── linked_accounts (individual bank accounts)
├── → assets (user links a brokerage account to a "Brokerage" asset)
└── → liabilities (user links a mortgage account to a "Mortgage" liability)
A plaid_items table stores each institution connection — the access token, institution name, and sync cursors. A linked_accounts table stores individual accounts within each institution — account name, type, subtype, and current balance.
These connect to the app's assets and liabilities tables via optional foreign keys. When a user links their Vanguard brokerage account to a "Brokerage" asset in FreedomTrack, syncing balances automatically updates that asset's value. But the user explicitly creates that link — connecting an institution doesn't automatically overwrite anything.
This separation means users can unlink an account at any time and go back to manual entry without losing data. Plaid is a data source, not the source of truth.
Transaction Staging: Don't Auto-Import Everything
Transactions were the trickiest piece to get right. The naive approach would be: sync transactions from Plaid, dump them into the user's expense tracker, done. But bank transactions are messy. Transfers between your own accounts show up as both a debit and a credit. Pending transactions can change amounts or disappear entirely. Categories from Plaid don't always match how a user thinks about their spending.
Instead, I built a staging pattern:
- Sync — Transactions land in a
plaid_transactionstable using Plaid's sync endpoint, which gives you added, modified, and removed transactions incrementally (no need to re-fetch everything) - Review — Users see their transactions in a review UI where they can categorize, annotate, or skip them
- Import — Selected transactions get pulled into the app's expense/income tables
This keeps users in control. They see everything Plaid gives them but choose what enters their financial picture. The sync endpoint's cursor-based approach means each sync only fetches what's changed since last time — efficient even for accounts with heavy transaction volume.
Investment Holdings: More Than Just Balances
For a financial independence app, investment data is where Plaid really shines. Beyond just the total account balance, you get:
- Individual holdings — each position with its ticker, quantity, and current value
- Cost basis — what the user originally paid, enabling gain/loss calculations
- Security metadata — whether it's a stock, ETF, mutual fund, cryptocurrency, etc.
I sync this into a plaid_holdings table linked to the account. The app can then show a user's total investment allocation, track individual positions over time, and calculate how close they are to their financial independence target — all without the user lifting a finger after the initial connection.
Handling the Rough Edges
Plaid's sandbox environment is excellent for development, but there are quirks to watch for:
Account selection behaves differently across environments. In sandbox, you get all accounts from the test institution. In development and production, users can select which accounts to share. Your UI needs to handle a user connecting an institution but only sharing two of their five accounts.
Webhooks vs. polling. Plaid recommends webhooks for transaction updates, but for a smaller app where users trigger syncs manually, polling via the sync endpoint works fine and avoids the infrastructure complexity of webhook receivers. I went with user-initiated sync — they click a refresh button, the edge function calls the sync endpoint, done.
Token expiration and re-authentication. Access tokens can break when a user changes their bank password or their bank's connection with Plaid has issues. You need an "update mode" flow where users can re-authenticate through Plaid Link without creating a new connection. The link token creation endpoint accepts an existing access token for this purpose.
Rate limits and error handling. Plaid returns specific error codes for different failure modes — ITEM_LOGIN_REQUIRED means re-authentication is needed, INSTITUTION_NOT_RESPONDING means try again later. Handling these gracefully in the UI is important for trust — users get nervous when bank connections show errors.
Security: Earning the Trust
When users connect bank accounts to your app, you're accepting serious responsibility. A few things I implemented beyond the basics:
Column-level encryption for access tokens using pgcrypto and Supabase Vault. Even if someone gained database access, tokens are unreadable without the Vault key. The encrypt/decrypt functions are restricted to the service role.
Row Level Security on every Plaid-related table. The auth.uid() = user_id pattern ensures users can only ever see and sync their own connections. No admin endpoint accidentally leaks another user's bank data.
Audit logging for sensitive operations — linking accounts, removing connections, syncing data. A simple table that records who did what and when.
MFA support — Plaid requires this for production access, and frankly any app touching bank data should have it. Supabase Auth supports TOTP natively, so it was mostly a matter of building the enrollment UI.
Getting production access from Plaid also requires data deletion capabilities, a privacy policy, and documentation of your security practices — reasonable requirements that push you toward building a more trustworthy product.
Lessons Learned
Choose infrastructure that handles the hard parts. Supabase gave me Row Level Security, built-in auth with MFA, Vault for secrets, and pg_cron for scheduled jobs. On a more barebones stack, I'd have spent weeks building what amounted to configuration changes.
Keep the integration loosely coupled. The separation between Plaid tables and domain tables paid for itself immediately. When I changed how assets worked, the Plaid integration didn't need to change. When Plaid changed their transaction schema, the app's expense tables didn't care.
Let users stay in control. The staging pattern for transactions and the explicit linking for balances both serve the same principle: connected data should inform the user's financial picture, not dictate it. Users trust the feature more when they feel in control of it.
Start with fewer Plaid products and expand. I started with just balances, got that working end-to-end, then added transactions, then investments. Each product has its own sync patterns, error modes, and data models. Trying to build them all at once would have been a mess.
The Plaid integration took FreedomTrack from a manual spreadsheet replacement to something that genuinely saves users time. The API itself is well-documented and straightforward — the real engineering work is in the data modeling, sync patterns, and UX decisions around how automated data coexists with user control.
If you're building a product that needs financial data integrations or other complex API work, I'd love to chat. Hit the "Get in Touch" button below or connect on LinkedIn.
