Skip to main content

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.

Date: 2026-02-13
Reference: Plaid Transactions Docs, Add Transactions to your app, Transaction webhooks
This document summarizes a deep review of the Encore Health OS Plaid transactions implementation against official Plaid documentation.

Executive Summary

The implementation is largely aligned with Plaid’s Transactions product: Link token includes transactions and days_requested: 730, webhook URL is set, Sync API is used with cursor persistence, and TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION is handled. One critical gap and several optional improvements were identified.

What’s Done Well

Requirement (Plaid docs)ImplementationStatus
Link token: products includes transactionsplaid-create-link-token passes products: ['transactions']; createLinkToken uses it.
Link token: webhook for transaction updatesplaid-create-link-token sets webhook: ${supabaseUrl}/functions/v1/plaid-webhook.
Link token: transactions.days_requestedplaid-client.ts: body.transactions = { days_requested: 730 } (max 730).
Exchange public_tokenaccess_tokenplaid-exchange-token calls exchangePublicToken(public_token).
First sync activates SYNC_UPDATES_AVAILABLEInitial sync in plaid-exchange-token and manual/webhook/scheduled sync all call /transactions/sync.
Save next_cursor for next syncAll sync paths persist plaid_cursor: syncData.next_cursor on fa_bank_accounts.
TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATIONplaid-sync: on this error, cursor is cleared and sync retried without cursor.
Webhook: SYNC_UPDATES_AVAILABLE → call syncplaid-webhook: SYNC_UPDATES_AVAILABLE triggers autoSyncTransactions().
Webhook: TRANSACTIONS_REMOVEDplaid-webhook: marks lines by plaid_transaction_id as status: 'removed'.
Handle added, modified, removedAll sync handlers process added, modified, and removed from Sync response.
Webhook signature verificationplaid-webhook: JWT verification via Plaid-Verification and body SHA-256.
Re-auth / connection statusRe-auth errors update plaid_connection_status and plaid_sync_enabled.
Duplicate institution checkplaid-exchange-token: blocks exchange when plaid_institution_id already exists for org.

Critical Gap: Full Pagination (has_more Loop)

Plaid requirement:
If has_more is true, you must keep calling /transactions/sync with the new next_cursor until has_more is false. Only then persist the final cursor. During pagination, if TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION occurs, restart from the previous cursor (the one from the first page of the current update), not from the failed page.
Current behavior:
We call /transactions/sync once per sync (manual, webhook, scheduled, or initial). We persist whatever next_cursor and has_more we get and do not loop. So when Plaid returns has_more: true (e.g. 500 items per page), we only ingest the first page and lose the rest until the next webhook/sync.
Impact:
  • New connections or large backfills can have hundreds or thousands of transactions; only the first batch (e.g. 100–500) is stored.
  • Cursor is advanced as if we had consumed all pages, so the missing pages are never fetched in subsequent incremental syncs.
Recommendation:
Implement a while (has_more) loop in all sync paths:
  1. _shared/plaid-client.ts
    • Either keep syncTransactions as a single-call helper and add a new syncTransactionsFull(session) that loops and yields/aggregates pages, or
    • Have the callers (plaid-sync, plaid-webhook autoSyncTransactions, plaid-scheduled-sync, plaid-exchange-token initialSync) loop: call syncTransactions(accessToken, cursor), accumulate added/modified/removed, set cursor = data.next_cursor, and only exit when !data.has_more. On TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION, reset cursor to the cursor at the start of the current loop and restart the loop.
  2. Cursor preservation for mutation error:
    When starting a pagination run, store cursorBeforeFirstPage = currentCursor. In the loop, use cursor = data.next_cursor for the next request. If a request returns TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION, set cursor = cursorBeforeFirstPage and re-enter the loop (do not persist the failed next_cursor).
  3. Apply the same loop in:
    • supabase/functions/plaid-sync/index.ts
    • supabase/functions/plaid-webhook/index.ts (autoSyncTransactions)
    • supabase/functions/plaid-scheduled-sync/index.ts
    • supabase/functions/plaid-exchange-token/index.ts (initialSync)

Optional Improvements

1. Optional: count parameter for /transactions/sync

Plaid supports count (1–500, default 100) per request. We don’t pass it, so we use the default. For large syncs, increasing to 500 could reduce the number of round-trips inside the new pagination loop. Optional and not required for correctness.

2. Optional: Use initial_update_complete / historical_update_complete from webhook

Docs: For SYNC_UPDATES_AVAILABLE, initial_update_complete: true means the first ~30 days are ready; historical_update_complete: true means full history is ready. We don’t read these fields. They could be used to:
  • Show UI copy like “Recent transactions ready; full history still loading.”
  • Optionally call sync when initial_update_complete is true to show recent data sooner (docs say this is optional).
Current: We sync whenever we get SYNC_UPDATES_AVAILABLE, which is correct. Adding handling for these flags would be UX-only.

3. Optional: transactions_update_status from /transactions/sync response

The Sync response includes transactions_update_status (NOT_READY, INITIAL_UPDATE_COMPLETE, HISTORICAL_UPDATE_COMPLETE). We don’t persist it. It could be stored (e.g. on fa_bank_accounts or sync log) for support and UI. Not required for core behavior.

4. Optional: /transactions/refresh (add-on)

Docs: Optional add-on to force an on-demand refresh; then Plaid may fire SYNC_UPDATES_AVAILABLE. We don’t call /transactions/refresh. If you need a “Refresh transactions” button that doesn’t rely on Plaid’s normal schedule, you’d add an edge function that calls this endpoint (subject to Plaid product access).

5. Recurring transactions

Docs: /transactions/recurring/get is an add-on for recurring inflow/outflow streams. We don’t use it. No change needed unless you add that product.

6. Transaction amount sign

In plaid-client.ts, mapPlaidTransaction comments: “Plaid: positive = outflow (debit), negative = inflow (credit)”. We use amount = Math.abs(txn.amount) and transactionType = txn.amount >= 0 ? 'debit' : 'credit', which matches that convention. No change needed.

Webhook Payload

We handle:
  • webhook_type: 'TRANSACTIONS', webhook_code: SYNC_UPDATES_AVAILABLE, DEFAULT_UPDATE, HISTORICAL_UPDATE, TRANSACTIONS_REMOVED
  • webhook_type: 'ITEM', webhook_code: ERROR, PENDING_EXPIRATION, USER_PERMISSION_REVOKED, LOGIN_REPAIRED
Docs note that for Sync API integrations, SYNC_UPDATES_AVAILABLE is the main webhook; DEFAULT_UPDATE and HISTORICAL_UPDATE are legacy for /transactions/get. Handling them all is harmless and ensures we don’t miss an event.

Testing (Sandbox)

Docs recommend:
  • user_transactions_dynamic (with non-OAuth institution, e.g. First Platypus Bank ins_109508) for dynamic data and webhooks.
  • /sandbox/transactions/create to simulate new transactions and trigger webhooks.
Our integration tests use mocked webhooks and tokens; consider adding a test that runs the full pagination loop (e.g. with a mock that returns has_more: true for the first call) to ensure we don’t leave pages unconsumed.

Action Items

PriorityActionOwner
P0Implement full pagination (has_more loop) in all four sync call sites; preserve “cursor at start of run” and restart on TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION.Engineering
P1Add unit/integration test for pagination (multi-page sync and mutation-during-pagination restart).Engineering
P2Optional: Persist or use transactions_update_status / webhook initial_update_complete / historical_update_complete for UX or support.Product/Engineering
P2Optional: Add count: 500 to sync requests if we want fewer round-trips.Engineering

References