Documentation Index
Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
Use this file to discover all available pages before exploring further.
Version: 1.0.0
Created: 2026-01-28
Updated: 2026-01-29
Status: π Planned
Owner: FA (Finance & Accounting)
Overview
This document defines the integration contract between Encore Health OSβs FA module and Plaid for automated bank feed integration. Plaid provides broad institution coverage (11,000+ globally), OAuth-based authentication, and a developer-friendly API.
Integration Pattern
Type: External API Integration via Edge Functions
Direction: Outbound (Encore Health OS β Plaid) + Inbound (Webhooks)
Authentication: API Keys (client_id + secret)
Authentication Requirements
Environment Matrix
| Environment | URL | Notes |
|---|
| Sandbox | sandbox.plaid.com | Test data, no real banks |
| Development | development.plaid.com | Real banks, limited access |
| Production | production.plaid.com | Full access, requires approval |
Credentials
| Credential | Purpose | Storage |
|---|
PLAID_CLIENT_ID | Application identification | Supabase Secret |
PLAID_SECRET | API authentication | Supabase Secret |
PLAID_ENVIRONMENT | Environment selection | Supabase Secret |
PLAID_WEBHOOK_SECRET | Webhook signature verification | Supabase Secret |
API Endpoints
Plaid API (Outbound)
| Endpoint | Method | Purpose |
|---|
/link/token/create | POST | Create Link token for frontend |
/item/public_token/exchange | POST | Exchange public token for access token |
/transactions/sync | POST | Sync transactions (incremental) |
/accounts/get | POST | Get account details |
/accounts/balance/get | POST | Get account balances |
/item/remove | POST | Disconnect account |
/transfer/authorization/create | POST | Authorize a transfer (required before create) |
/transfer/create | POST | Create transfer using approved authorization_id |
/transfer/intent/create | POST | Create transfer intent for Transfer UI |
/transfer/intent/get | POST | Get transfer intent status and transfer_id |
Edge Functions (Internal)
| Function | Method | Purpose |
|---|
plaid-create-link-token | POST | Create Plaid Link token |
plaid-exchange-token | POST | Exchange public_token for access_token |
plaid-sync | POST | Sync transactions for account |
plaid-disconnect | POST | Disconnect Plaid account |
plaid-webhook | POST | Handle Plaid webhook events |
hr-plaid-transfer-create | POST | Create ACH credits for payroll (uses authorize then create) |
plaid-transfer-intent-create | POST | Create transfer intent for Transfer UI (one-time payment/disbursement) |
plaid-transfer-intent-get | POST | Get transfer intent status and transfer_id after Transfer UI |
plaid-create-link-token | POST | Supports transfer_intent_id + link_customization_name for Transfer UI |
Transfer UI (One-Time Payment / Disbursement)
Transfer UI is Plaidβs hosted flow for one-time debit or credit. It is not used for batch payroll (see Programmatic Plaid Transfer below).
Flow:
- Create intent: Frontend calls
plaid-transfer-intent-create with organization_id, mode (PAYMENT | DISBURSEMENT), amount, description, ach_class, user (e.g. legal_name). Backend calls Plaid POST /transfer/intent/create and returns transfer_intent_id.
- Link token: Frontend calls
plaid-create-link-token with organization_id, transfer_intent_id, and optional link_customization_name. Backend returns a link token with products: ["transfer"] and transfer.intent_id.
- Open Link: Frontend opens Plaid Link with that token; user authorizes the transfer in Plaidβs UI.
- Post-success: On Link
onSuccess, metadata.transfer_status is available; frontend may call plaid-transfer-intent-get with transfer_intent_id to get final status, transfer_id, and failure_reason.
Configuration:
- Origination account: Required for transfer intent. Resolved in order: (1)
fa_module_settings.plaid_origination_account_id for the organization, (2) env PLAID_ORIGINATION_ACCOUNT_ID. At least one must be set.
- Link customization: In Plaid Dashboard, create a Link customization with Account Select: Enabled for one account. Optionally set
fa_module_settings.plaid_link_customization_name per org, or pass link_customization_name when requesting the link token / from frontend (useTransferUI / TransferUIButton).
Frontend: Platform banking exposes useTransferUI and TransferUIButton from @/platform/banking; they orchestrate intent create β link token β Link open β optional intent/get.
Programmatic Plaid Transfer (HR Payroll)
Payroll direct deposit uses Plaid Transfer in a two-step flow. Per Plaid: Creating transfers:
- Authorize: Call
POST /transfer/authorization/create with access_token, account_id, type, network, amount, ach_class, user, and an idempotency_key. Plaid returns authorization_id and decision (approved/declined).
- Create: If approved, call
POST /transfer/create with authorization_id, access_token, account_id, and description only.
The hr-plaid-transfer-create edge function uses the shared plaid-client.ts (createTransferAuthorization, createTransfer) and passes an idempotency key per payment item (e.g. hr-pay-{batchId}-{itemId}) to avoid duplicate transfers on retries. ACH description is limited to 10 characters (e.g. PAY + batch number suffix).
Data Flow
βββββββββββββββββββ βββββββββββββββββββ
β Encore Health OS β β Plaid API β
β Frontend β β β
ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ
β β
β 1. Request Link Token β
β (to Edge Function) β
βΌ β
βββββββββββββββββββ β
β plaid-create- β 2. POST /link/token/createβ
β link-token ββββββββββββββββββββββββββββΊβ
β β β
β β 3. link_token β
β βββββββββββββββββββββββββββββ€
ββββββββββ¬βββββββββ β
β β
β 4. link_token to frontend β
βΌ β
βββββββββββββββββββ β
β Plaid Link β 5. User authenticates β
β (Frontend) ββββββββββββββββββββββββββββΊβ
β β β
β β 6. public_token β
β βββββββββββββββββββββββββββββ€
ββββββββββ¬βββββββββ β
β β
β 7. POST /plaid-exchange-token β
βΌ β
βββββββββββββββββββ 8. POST /item/public_ β
β plaid-exchange- β token/exchange β
β token ββββββββββββββββββββββββββββΊβ
β β β
β β 9. access_token + accountsβ
β βββββββββββββββββββββββββββββ€
β β β
β β 10. Store in fa_bank_accounts
ββββββββββ¬βββββββββ β
β β
β 11. Webhook: INITIAL_UPDATE β
ββββββββββββββββββββββββββββββββββββββ€
β β
βΌ β
βββββββββββββββββββ 12. POST /transactions/sync
β plaid-sync ββββββββββββββββββββββββββββΊβ
β β β
β β 13. transactions + cursor β
β βββββββββββββββββββββββββββββ€
β β β
β β 14. Store in fa_bank_statement_lines
βββββββββββββββββββ
Webhook Events
Supported Events
| Event | Action |
|---|
INITIAL_UPDATE | Trigger initial transaction sync |
HISTORICAL_UPDATE | Trigger historical transaction sync |
DEFAULT_UPDATE | Trigger incremental transaction sync |
TRANSACTIONS_REMOVED | Mark removed transactions |
ITEM_LOGIN_REQUIRED | Set status to reauth_required |
ERROR | Log error, potentially set status to error |
Signature Verification
Header: Plaid-Verification
The webhook body is signed using JWT. Verification requires:
- Fetch Plaidβs verification key
- Verify JWT signature
- Validate claims (iat, request_body_sha256)
// Webhook verification (simplified)
const verifyPlaidWebhook = async (body: string, verification: string) => {
// 1. Decode JWT header to get key_id
// 2. Fetch verification key from Plaid
// 3. Verify JWT signature
// 4. Verify request_body_sha256 matches
return isValid;
};
Database Schema
fa_bank_accounts (Additional Columns)
| Column | Type | Purpose |
|---|
plaid_account_id | TEXT | Plaid account ID |
plaid_item_id | TEXT | Plaid Item ID (groups accounts) |
plaid_access_token | TEXT | API access token (encrypted) |
plaid_institution_id | TEXT | Institution identifier |
plaid_sync_enabled | BOOLEAN | Auto-sync enabled flag |
plaid_last_synced_at | TIMESTAMPTZ | Last sync timestamp |
plaid_cursor | TEXT | Sync API pagination cursor |
plaid_connection_status | TEXT | connected/disconnected/error/reauth_required |
bank_provider | TEXT | manual/teller/plaid |
fa_bank_statement_lines (Additional Column)
| Column | Type | Purpose |
|---|
plaid_transaction_id | TEXT | Plaid transaction ID for deduplication |
fa_module_settings (Additional Columns)
| Column | Type | Purpose |
|---|
plaid_integration_enabled | BOOLEAN | Feature flag |
plaid_origination_account_id | TEXT | Transfer UI: Plaid origination (funding) account ID; overrides env when set |
plaid_link_customization_name | TEXT | Transfer UI: Link customization name (Account Select: one account) from Plaid Dashboard |
Security Considerations
Token Storage
- Access tokens stored in
plaid_access_token column (encrypted at rest by Supabase)
- Tokens never exposed to frontend
- Edge functions access tokens via service role
Multi-Tenancy
- All Edge Function queries include
organization_id filter
- RLS policies prevent cross-tenant access
- Audit logging for all Plaid operations
Webhook Security
- JWT signature verification
- Request body hash validation
- Rate limiting via Supabase
Institution-specific behavior (Duplicate Items)
For Chase, PNC, Navy Federal Credit Union, and Charles Schwab, linking the same credentials again (creating a second Plaid Item for the same institution) can invalidate the existing Item at the time the user completes the institutionβs OAuth flow. When this occurs:
- Option 1: Use Plaidβs update mode to repair the existing Item (re-launch Link with the existing
access_token). That will invalidate the new Item; then remove the new Item via /item/remove if it was already created.
- Option 2: Remove the old Item using Plaidβs
/item/remove endpoint and use the new Item.
Encore Health OS prevents duplicate Items per organization by checking plaid_institution_id before exchanging the public token (see duplicate prevention in implementation). This runbook is for awareness when handling support cases or when adding Item-level health handling (e.g. webhooks) that may replace an invalidated Item.
Error Handling
Plaid API Errors
| Error Code | Meaning | Action |
|---|
ITEM_LOGIN_REQUIRED | User needs to re-authenticate | Set status to reauth_required |
INVALID_ACCESS_TOKEN | Token invalid or revoked | Set status to disconnected |
RATE_LIMIT_EXCEEDED | Too many requests | Exponential backoff, retry |
INTERNAL_SERVER_ERROR | Plaid error | Log, retry with backoff |
Retry Strategy
// Exponential backoff for rate limiting
const delays = [1000, 2000, 4000, 8000, 16000]; // ms
for (const delay of delays) {
const result = await callPlaidApi();
if (result.error_code !== 'RATE_LIMIT_EXCEEDED') break;
await sleep(delay);
}
Logging
Allowlisted Fields (Safe to Log)
bank_account_id
plaid_item_id (not access token)
plaid_account_id
transactions_synced
sync_duration_ms
Prohibited (Never Log)
plaid_access_token
plaid_secret
- Full account numbers
Testing Strategy
Sandbox Testing
- Use Plaid sandbox environment
- Test institutions:
ins_109508 (Chase), ins_109511 (Bank of America)
- Test credentials:
user_good / any password
Integration Tests
- Link token creation
- Token exchange
- Transaction sync with deduplication
- Webhook signature verification
E2E Tests
- Full connect β sync β disconnect flow
Rollback Strategy
All Plaid columns are nullable. Rollback procedure:
- Disable feature flag:
plaid_integration_enabled = false
- UI hides all Plaid components
- Manual CSV/OFX import continues to work
- No data migration required
Account selection and GL mapping
After Plaid Link succeeds, Encore Health OS uses a deferred exchange flow when onPlaidLinkSuccess is set:
- Link returns
public_token and metadata.accounts (all accounts the user linked at the institution).
- The app shows PlaidAccountMappingDialog: user selects which accounts to connect and assigns a GL account to each.
- Only selected accounts are sent to
plaid-exchange-token with gl_account_id per account.
- Backend creates
fa_bank_accounts rows and triggers initial sync for each.
Backend contract: plaid-exchange-token accepts accounts[] with optional gl_account_id. If provided, that GL is used (and must be an asset, posting-allowed account in the same organization); otherwise the server falls back to default cash/asset lookup.
Plaid docs: metadata.accounts in the Link onSuccess callback contains id, name, mask, type, subtype. Account selection in Link is controlled by the user inside Plaidβs UI; we do not filter which accounts appear in Link for initial link (optional: use account_filters when creating the link token to limit types/subtypes).
Recommendations (from Plaid docs and product)
| Recommendation | Purpose |
|---|
| Account/GL mapping dialog | Let users choose which linked accounts to import and which GL to use (implemented). |
| account_filters (optional) | When creating the link token, pass account_filters (e.g. depository.account_subtypes: ['checking','savings'], credit.account_subtypes: ['credit card']) so Link only shows relevant account types. |
| transactions.days_requested | Request up to 730 days of history in link token if product supports it; improves initial sync. |
| Update mode | For existing Items, use update.account_selection_enabled: true and access_token when creating the link token so users can add/remove accounts without reconnecting. |
| Link customization | Use link_customization_name for a consistent/branded Link experience if configured in Plaid Dashboard. |
| Error surfacing | When exchange returns accounts: [], surface errors[] from the response (e.g. βNo GL account foundβ¦β) so users can fix setup (implemented). |
Product initialization
We request Transactions and Balance when creating Link tokens. The Balance product is required for /accounts/balance/get and for populating fa_bank_accounts.balance; without it, Bank Summary and Cash Allocation widgets will show zero or omit the account. Existing Items created with only transactions do not have Balanceβre-linking (or Plaid update mode with balance added) is needed to get balance. When we add Auth or Identity, consider optional_products (Auth) or required_if_supported_products (Identity) per Plaidβs Choosing when to initialize products.
Balance and cash position
When Plaid updates fa_bank_accounts.balance (in plaid-sync after a manual or post-exchange sync, or in plaid-webhook on SYNC_UPDATES_AVAILABLE), the same cash position flow as Teller is used: we invoke the fa-handle-bank-balance-updated edge function with organization_id, bank_account_id, old_balance, new_balance, and optional updated_by. That handler recalculates from all active bank accounts and upserts fa_cash_positions for the current date, so the Cash Allocation & Credit widget stays in sync with Plaid balance changes.
References