Plaid Integration Audit

Date: 2026-05-06
Repos with Plaid references: 27


Executive Summary

Plaid is deeply embedded across the stack. The integration touches mobile clients, multiple backend services, a data warehouse pipeline, and a machine learning underwriting model. transactions-service is the canonical hub — it holds the most current SDK version, manages all Plaid credentials, stores access tokens in DynamoDB, and is the only service with a complete webhook pipeline. Most other services consume Plaid data indirectly through it.

Engineering manager context (2026-05-06): credit-card-service can be archived; floatme-api is no longer used except for one user endpoint; insight-service may have stale Plaid code. The active Plaid footprint on the backend is essentially just transactions-service, plus qa-automation for test infrastructure and floatme-flutter on the client.


Key Concepts & Definitions

Before reading the product and implementation sections, these terms and distinctions are worth establishing clearly. Several of them are frequently confused.


Item

A Plaid Item represents a single connection between one user and one financial institution. Linking Chase is one Item. Linking a second bank is a second Item. An Item is not a product or a charge — it is Plaid’s unit of measurement and the container that all data products and access tokens are attached to. See the pricing note in the Products section for billing implications.


access_token and public_token

When a user completes Plaid Link, the app receives a short-lived public_token (valid ~10 minutes) representing the user’s consent. This is immediately POSTed to transactions-service, which exchanges it via /item/public_token/exchange for a persistent access_token. The access_token is the long-lived credential FloatMe holds — it is stored encrypted in DynamoDB and used for all subsequent Plaid API calls for that user’s Item. The public_token is discarded after exchange.


Tokenized Account Numbers (TANs)

A TAN is a bank-issued virtual routing and account number. When a user at a TAN-supporting institution (currently Chase, PNC, and US Bank) authenticates via Plaid OAuth, Plaid’s /auth/get does not return their real account and routing numbers — instead the bank issues a virtual number pair that:

  • Works identically to real ACH credentials for initiating debits and credits on the payment rails

  • Is scoped to FloatMe’s Plaid connection — if submitted by another party, the bank rejects it

  • Changes on each re-link — the same user linking the same bank account a second time gets a different TAN, because the old connection was severed and a new one created

  • Stops working for withdrawals if the user revokes consent — ACH deposits still route (since money in is non-harmful) but withdrawals are blocked

TANs are a bank-level security measure that limits the blast radius if FloatMe’s stored credentials were ever compromised — the credentials only work for FloatMe, and only while the user’s consent is active.

TANs travel on the same ACH rails as real account numbers — the network has no idea. A TAN is submitted in a NACHA file exactly like any other routing and account number pair. FedACH and The Clearing House process it identically to a normal ACH transaction — neither operator is aware it is tokenized. The translation from TAN to real account happens entirely inside the issuing bank’s internal systems when the transaction arrives at the RDFI (Receiving Depository Financial Institution). From the ACH network’s perspective, it is just a number.

TANs are only valid on ACH and RTP payment rails — they cannot be used for wire transfers or physical checks.

Format note: Chase TANs are 17 digits, beginning with 00. This non-standard length is how Chase’s systems recognize an incoming ACH transaction as carrying a TAN rather than a real account number, and trigger the internal mapping. The two leading zeros may be dropped by some systems but the remaining 15 digits must be preserved intact in the NACHA file.

A critical pairing requirement: A TAN account number must always be submitted with its corresponding TAN routing number. Mixing a TAN account number with a real routing number, or a real account number with a TAN routing number, results in an R04 return (Invalid Account Number). If a user revokes consent, the TAN is invalidated and subsequent ACH withdrawal attempts also return R04.

FloatMe’s handling: payments-service is aware of TANs. The Auth response includes a PersistentAccountId — a stable Plaid-assigned identifier for the underlying real account that remains constant even as TANs change across re-links. payments-service uses PersistentAccountId as the basis for its duplicate-account detection hash (secureHashAccountRouting in pkg/bankaccount/store/record.go) — when present, it hashes the persistent ID instead of the TAN itself, since the TAN would change across re-links while the real account stays the same.


Processor Tokens

A Processor Token is a Plaid-issued access delegation token that allows a specific Plaid partner to call Plaid’s API on FloatMe’s behalf. FloatMe uses this for Array (bank-as-a-service). The flow is:

FloatMe → asks Plaid for a processor token scoped to Array
Array → calls Plaid using that token
Plaid → calls the bank on Array's behalf
Bank → only ever sees Plaid, not Array

Processor Tokens are entirely within Plaid’s ecosystem. The bank does not issue them. They are not account numbers. They grant API access, not payment rail access.


TANs vs Processor Tokens — side by side

These two technologies are frequently conflated because both involve "tokens" and both relate to bank account access. They are fundamentally different:

TAN (Tokenized Account Number) Processor Token

Issued by

The bank (Chase, PNC, US Bank)

Plaid

What it is

A virtual routing + account number

An API access credential

Used on

The ACH payment rails directly

Plaid’s API

Scoped to

FloatMe’s Plaid connection

A specific Plaid processor partner (Array)

Returned by

Plaid /auth/get (transparently)

Plaid /processor/token/create (explicitly)

If transmitted to another party

Bank rejects it — wrong client

Plaid rejects it — wrong processor

Revocation effect

Withdrawals stop; deposits still route

Token stops working entirely

Stable across re-links?

No — changes on each re-link

N/A (created on demand)

Stable identifier available?

Yes — PersistentAccountId

N/A

FloatMe awareness

Transparent — handled via PersistentAccountId

Explicit — created intentionally for Array


Plaid Products In Use

Terminology — what is an "Item"? In Plaid’s model, an Item is a single connection between one user and one financial institution. When a user links their Chase account, that creates one Item. If they later link a second bank, that is a second Item. "Item" is not a product or a charge — it is Plaid’s unit of measurement. When pricing says "$X/month per Item," it means "$X/month per connected user-bank pair." There is no standalone fee for Items themselves.

Pricing note: Plaid does not publish a unified rate card. List prices below are directional estimates from public sources and third-party benchmarks — your actual negotiated rates will differ. FloatMe’s actual unit costs are derivable from the invoice data modeled in dbt-floatme/int_finance__plaid_costs.sql. Plaid uses three billing models: one-time (charged once per Item, on first use), monthly subscription (per Item per month, for as long as the Item exists), and per-request (flat fee per successful API call).


User consent mechanism and bank connection UI. Not a data product — the legal and technical entry point that gates all others.

Pricing: Free. /link/token/create is not billed; costs are incurred when the resulting Item is used with a billed product downstream.

Link serves two distinct purposes simultaneously:

  1. Legal consent. Link is the mechanism by which a user formally authorizes FloatMe to access their bank data through Plaid. Plaid hosts the consent screen, presents the data permissions in plain language (which products will be accessed and why), and records the user’s explicit agreement. This consent is what makes all downstream data access legally permissible. Without a completed Link flow, FloatMe has no right to call any Plaid data API for that user.

  2. UI tool. Link is also the practical interface through which the user selects their institution, enters their banking credentials, and completes any required MFA. Plaid manages the entire experience — institution search, credential handling, OAuth redirects — so FloatMe never sees or handles the user’s bank login details.

When both purposes are satisfied — consent given and credentials verified — Plaid returns a short-lived public_token. No bank data has been returned at this point; the token only represents that the user consented and authenticated successfully. The public_token must be exchanged server-side for a persistent access_token before any data products can be called.

Services

transactions-service; floatme-api (retiring)

Endpoints

/link/token/create, /item/public_token/exchange, /item/webhook/update

Notes

Standard linking and update mode both supported. Institution blocking for Varo/Chime enforced in Flutter app via remote config. iOS redirect URI and Link customization name configured per-service via env var.

At FloatMe — when Link is invoked:

Plaid Link is opened in six distinct situations across the app:

  1. New user signup (onboarding)onboarding_v3_base_screen.dart navigates to ConnectAccountsPage, which renders ConnectBankWidget. This is the primary path — every new user links their bank as part of signup. If the FeatureFlags.plaidLayer GrowthBook flag is enabled, the Layer MFA flow is attempted first on the mfa_signup_page.dart before standard Link.

  2. Reactivationreactivation_screen_v2.dart navigates to ConnectAccountsPage when a user’s account needs reactivation (e.g. after a failed collection or expired session). Same ConnectBankWidget flow as onboarding.

  3. Change bank (profile) — from the profile screen, linked_account_vm.dart navigates to ChangePlaidScreenFromLinkedAccountScreen. The user can swap their linked institution. This uses update mode — the existing access_token is passed to Plaid so the user doesn’t have to start from scratch.

  4. Add additional bank accountconnected_accounts_screen_v2.dart and manage_plaid_connections_screen.dart both call AddPlaidItemUseCase.execute(), allowing users to connect a second bank account from the Connected Accounts management section.

  5. Debit card screendebit_cards_screen.dart navigates to ConnectAccountsPage in a context where the user is managing payment methods.

  6. LOC (Line of Credit) onboardingloc_onboarding_viewmodel.dart opens a Link modal directly via _openPlaidLink() using a link token fetched from the LOC repository. This is a separate flow for the Prosper/LOC credit product, independent of the main float onboarding.

At FloatMe — how it works: transactions-service creates the link token via GetLinkToken() (pkg/plaid/plaid.go), requesting TRANSACTIONS (required), AUTH (optional), and LIABILITIES (additional consented). Platform routing differs: iOS gets a RedirectUri; Android gets AndroidPackageName = "io.floatme.floatmeapp". For change-bank flows, the current access_token is passed and AccountSelectionEnabled: true is set — update mode. In the Flutter app, PlaidLinkInitService.initializeAndLinkPlaid() fetches the link token, initializes PlaidLink.create(), and opens the modal. On success, the onSuccess callback filters to checking accounts only (LinkAccountSubtypeDepository.checking). The public_token and account list are POSTed to transactions-service at POST /users/{userId}/transactions/items-v2, which exchanges it for a permanent access_token, stores the Item in DynamoDB, and registers the webhook URL.


Transactions

Enriched, categorized history of all bank activity on a connected account.

Pricing: Monthly subscription per Item. Billing starts at Item creation — not on the first API call — because Transactions is set as a required product in FloatMe’s link token. Every Item that exists in DynamoDB is generating a monthly charge, regardless of whether any /transactions/get calls have been made, whether the user is active, or whether the Item is in an error state. Charges stop only when the Item is explicitly removed via /item/remove. The optional /transactions/refresh endpoint is billed separately per call.

An Item is created whenever a user successfully completes a Plaid Link flow that results in a new bank connection. Specifically, the Item is created in transactions-service at the moment /item/public_token/exchange is called — that is, after the user has authenticated with their bank in the Link modal and the app POSTs the resulting public_token to the backend. This happens in any of the six Link entry points documented above: new user onboarding, reactivation, adding a second bank account, the LOC onboarding flow, and the debit card screen. Changing an existing bank via update mode does not create a new Item — it re-authenticates against the existing one. Only flows that result in a brand-new bank connection (a new public_token exchange) create a new billable Item.

Transactions provides a full history of the user’s bank activity — debits, credits, transfers, and payments — going back up to 24 months. Plaid normalizes transaction names, assigns merchant categories, and provides personal finance categories. Data arrives via webhooks rather than polling: Plaid notifies you when new transactions are available, and you fetch them. This is the primary data source for FloatMe’s underwriting rules and the 37-feature ML model.

Services

transactions-service

Endpoints

/transactions/get, /transactions/refresh

Flow

Webhook → SQS prod-txn-plaid-webhooks → miner Lambda → Kinesis prod-txn-plaid-transactions → refiner Lambda → DynamoDB → underwriting-api / ML pipeline

Notes

Legacy webhook handler in floatme-api to be decommissioned. Kinesis downstream to insight-service uncertain if still active.

At FloatMe: When an item is created, transactions-service immediately manufactures a synthetic INITIAL_UPDATE webhook and sends it directly to the miner SQS queue — without waiting for Plaid. This is a deliberate fail-safe to close a race condition: the webhook URL isn’t registered with Plaid until after the item is saved, so Plaid’s real INITIAL_UPDATE could fire to the wrong URL. See Key Finding #8 for detail. For Layer items, a synthetic HISTORICAL_UPDATE (1 year) is sent instead of INITIAL_UPDATE (30 days).

Ongoing webhooks arrive at the cmd/webhook Lambda, are enqueued to SQS prod-txn-plaid-webhooks, and processed by the cmd/miner Lambda. The miner routes by webhook code to determine fetch range: INITIAL_UPDATE = 30 days, DEFAULT_UPDATE = 14 days, HISTORICAL_UPDATE = 1 year, TRANSACTIONS_REMOVED = mark deleted. All fetches use a batch size of 500; overflow is queued to prod-txn-throttle SQS with an incremented offset. Completed batches are published to Kinesis prod-txn-plaid-transactions, consumed by the cmd/refiner Lambda, which writes to DynamoDB. Amounts are stored as int64 cents to avoid float precision loss.

Transaction data feeds the underwriting rule engine in underwriting-api: RuleRecurringDeposits detects payroll patterns by category ID and amount; RuleTransferRatio flags users sending >50% of income to third parties via PlaidPrimaryCategory; RuleEssentialSpend measures essential vs. discretionary spending via PlaidDetailCategory; RuleCashAdvanceScore counts ATM/cash-advance frequency. The same data is aggregated into 37 features for a CatBoost model (machine-learning/underwriting_v2), including inflow/outflow counts, competitor cash advance tracking, food spend, transfer delta, and cash balance. The model approval threshold is 0.55.


Auth

Account and routing numbers for a connected bank account. The data that makes ACH possible.

Pricing: One-time fee per Item. Auth is set as an optional_products in FloatMe’s link token — billing triggers on the first successful /auth/get call, not at Item creation. All subsequent calls to the same Item are free. FloatMe explicitly tracks this in DynamoDB (billed_products[]), manually appending auth on first call.

Auth retrieves the bank account and routing numbers needed to initiate ACH transfers. It is entirely separate from Link: Link proves the user consented to the connection; Auth retrieves the account details needed to move money. You can complete Link without ever calling Auth (e.g., if you only want Transactions). In FloatMe’s flow, the Flutter app POSTs the public_token to transactions-service, which exchanges it for an access_token stored in DynamoDB. /auth/get is called separately when routing and account numbers are needed for ACH prenotes or collection debits.

Important: for Chase, PNC, and US Bank users, Auth does not return real account numbers. These institutions issue TANs (Tokenized Account Numbers) — virtual routing and account numbers scoped to FloatMe’s connection. See the Tokenized Account Numbers (TANs) section above. The Auth response also includes a PersistentAccountId field — a stable identifier for the underlying real account that persists across re-links even as TANs change. FloatMe uses this as the basis for duplicate account detection.

Services

transactions-service, payments-service; credit-card-service (archiving), floatme-api (retiring)

Endpoints

/auth/get

Notes

Lazy — only called when a payment is being initiated, not at link time. Access restricted to payments-api service. Billing tracked explicitly in DynamoDB billed_products[].

At FloatMe — when Auth data is obtained:

Auth is fetched at two specific moments in the user journey, and is cached after the first fetch so Plaid is not called repeatedly.

Moment 1 — 3-day pre-subscription prenote (ongoing users). The notifier-scheduler Lambda runs daily at 12:00 UTC and identifies users with an upcoming subscription billing date. For each, the notifier-worker Lambda fires a 3-day advance notification to the user and submits a zero-dollar ACH prenote to Usio or JPM to pre-verify the bank account is still valid before the real debit. It is during this prenote submission that Auth data (routing number + account number) is first required. The prenote fires for new users (account age < 30 days) unconditionally, and for established users only if they have a successful payment in the last 40 days.

Moment 2 — first ACH payment attempt (new users and cache misses). In payments-service, bankaccount.Get() (pkg/bankaccount/bankaccount.go) implements a DynamoDB cache for routing and account numbers. When a payment is initiated — subscription collection, float repayment, or prenote — this function first checks the DynamoDB cache. On a cache miss (first time for that user, or after a bank account change), it calls transactions-service to fetch Auth from Plaid, stores the result in DynamoDB, and proceeds. All subsequent payment attempts for the same user hit the cache and do not call Plaid again.

The practical implication: For most users, the one-time Plaid Auth fee is triggered at the 3-day prenote before their first subscription payment — not at the moment they link their bank. If a user links their bank but cancels before their first subscription cycle, Auth may never be called and the one-time fee may never be incurred.

The endpoint GET /users/{user_id}/items/{item_id}/accounts/{account_id}/auth (pkg/api/plaid_auth.go) is restricted to the payments-api service only via IAM ARN check. On the first successful call it adds Auth to BilledProducts in DynamoDB, explicitly tracking the one-time billing event. Return codes R05, R08, R10, R20, R29, R51 on subsequent ACH payments trigger automatic user banlisting.


Balances

Real-time point-in-time account balance. The most expensive product per call.

Pricing: Per-request flat fee. Every successful call to /accounts/balance/get is billed individually — no subscription, no one-time model. Call volume directly drives cost.

Balances returns the current available and ledger balance for a user’s connected accounts at the moment of the API call. Unlike Transactions (historical, webhook-driven), this is a live synchronous lookup — Plaid queries the institution in real time. This makes it slower and more expensive per call than most other products. FloatMe uses it in two places: during underwriting to check if a user has sufficient funds, and in the collections retry loop to decide whether to attempt repayment.

Why not derive balance from transaction history? You could theoretically sum deposits minus withdrawals from Transactions data to reconstruct a running balance — but this is unreliable in practice. Plaid transactions have 1–3 days of latency, and pending transactions (authorized but not yet settled) are a separate, incomplete dataset. A computed balance would routinely be wrong by the amount of any activity in the last few days. The Balances product sidesteps this by querying the institution directly at call time.

What the balance fields actually mean:

  • available_balance — what the bank will let the user spend right now, factoring in holds, pending debits, and overdraft limits

  • current_balance — the settled ledger balance only; ignores anything pending

For FloatMe’s collection use case, available_balance is the relevant figure. Using transaction-derived balance would mean initiating an ACH debit based on data that may be days stale — a direct ACH return risk. The per-call cost of Balances is essentially the cost of that certainty.

Caveat: "real-time" is institution-dependent. The Capital One 3-day lookback requirement in fmsdk is an example of an institution that caches balance data on Plaid’s side rather than serving a live query. Balances is more real-time than Transactions, but the degree varies by bank.

Services

transactions-service; float-service, underwriting-service (both indirect via txns-service API)

Endpoints

/accounts/balance/get

Notes

High-sensitivity cost item — every call is billed. Capital One requires 3-day lookback (hardcoded in fmsdk). float-service uses $10 buffer on available_balance before retrying collection.

At FloatMe — when balance is fetched and how often:

There are two distinct balance concepts in the codebase: a live Plaid call (/accounts/balance/get) and cached balance reads from DynamoDB. They are used in different situations.

Live Plaid callgetAndCacheLiveBalances() in transactions-service/pkg/api/accounts.go — calls /accounts/balance/get and writes the result to DynamoDB. This is only triggered when live=true is passed to the accounts endpoint. Searching the entire codebase, WithLive(true) is never called by any backend service — every service that sets the flag explicitly uses WithLive(false). The live=true path therefore exists for external or admin use cases (e.g. a direct API call or backoffice query) but is not part of any regular automated flow. The Capital One 3-day lookback is hardcoded here.

Cached balance reads — all regular automated flows read balance data that was previously written to DynamoDB, not from Plaid directly:

  1. Float collection retry (float-service/pkg/collections/retry.go) — when a float repayment fails and is being retried, ProcessRetry() reads the cached account balance via GetPlaidAccountsWithResponse() with WithLive(false). It applies a $10 buffer: if available_balance > float_amount + $10, the retry proceeds; otherwise it records OutcomeLowBalance. If the Plaid item is in error state: no debit card on file → StatusUncollectable; debit card present → OutcomeInvalidPlaid, retry tomorrow. This can run multiple times across the life of a failed float.

  2. Subscription collection (income event trigger) (subscription-service/pkg/collections/webhook-worker.go) — when a Plaid income_txn event above a threshold arrives via EventBridge, the webhook worker calls doBalanceCheck() to confirm the user has sufficient funds before attempting a subscription payment. Uses WithLive(false) — reads cached balance.

  3. Subscription collection (new account event trigger) — when a user adds or updates their bank account, transactions-service fires a new_account EventBridge event, which triggers the webhook-balance-worker Lambda in subscription-service. This also reads cached balance to evaluate whether to attempt a subscription payment.

  4. Underwriting rule evaluation (underwriting-api/pkg/rulerunner/runner.go) — GetAccountBalances() reads up to 32 days of historical balance snapshots from DynamoDB. RuleBalanceRequirement enforces configurable min_available and min_current thresholds, and RuleSuspiciousHighBalance denies new accounts with no prior floats but an unusually high balance.

  5. Subscription ML decider (subscription-service/pkg/mldecider/MLDecider.go) — reads 7 days of daily balance snapshots (Balance1d through Balance7d) plus a BalanceSlope trend from DynamoDB to feed the subscription payment ML model.

So where does the cached balance data come from? The DynamoDB balance records are populated whenever transactions-service fetches accounts from Plaid — primarily as a side effect of transaction sync (balances come back with every /transactions/get response and are cached) and when the miner processes webhooks. The balance in DynamoDB at any given moment reflects the last time a transaction webhook was processed for that user, which typically means it is 1–3 days stale. This is why the live=true path exists as a safety valve for ACH-sensitive operations — though as noted above, it appears not to be called in current automated flows.


Liabilities

Structured debt data: balances, payment due dates, interest rates, and loan terms.

Pricing: Monthly subscription per Item. Unlike Transactions, Liabilities is set as an additional_consented_products in FloatMe’s link token — meaning the user consents during Link but the product is not initialized. Billing only triggers on the first actual /liabilities/get call. Since ingestion is gated by the EnableLiabilityMining flag and driven by webhooks, an Item could have Liabilities consented but never billed if no LIABILITIES webhook has ever fired for it.

Liabilities returns structured debt information for credit accounts — credit cards, student loans, and mortgages. For a credit card: current balance, minimum payment due, payment due date, last payment amount, and APR. For loans: outstanding principal, interest rate, and origination details. This is distinct from Transactions: Transactions shows individual payments; Liabilities shows the underlying debt structure. FloatMe uses it to understand a user’s existing obligations as part of underwriting.

Services

transactions-service; floatme-api (retiring)

Endpoints

/liabilities/get

Types tracked

Credit cards, student loans, mortgages

Notes

Stored but not yet used in underwriting rules. EnableLiabilityMining feature flag controls ingestion.

At FloatMe — when liability data is fetched:

Liabilities are fetched only when Plaid fires a LIABILITIES / DEFAULT_UPDATE webhook — which happens when Plaid detects a change to the user’s credit accounts (new statement, payment made, balance change, etc.). The miner Lambda processes this webhook, extracts the affected account_ids_with_new_liabilities and account_ids_with_updated_liabilities from the payload, and calls /liabilities/get only for those accounts. This is entirely reactive — FloatMe never proactively polls for liability data. Ingestion is additionally gated by an EnableLiabilityMining feature flag; if the flag is off, the miner ignores all LIABILITIES webhooks entirely.

Data is stored in two DynamoDB patterns: a current snapshot (overwritten on each update) and a timestamped history record for audit trail.

At FloatMe — when liability data is used:

This is where the story gets significant. Searching the entire codebase for liability data consumption:

  • underwriting-api — explicitly passes IncludeLiabilities: aws.Bool(false) when listing Items for rule evaluation. Liability data is actively excluded from underwriting.

  • admin-api / backofficeGetLiabilitiesByItemID is exposed as an admin API endpoint, surfaced only in the QA section of the backoffice (qa-accounts.tsx) for test user configuration. It is not displayed in the main member view.

  • No other service reads liability data. There are no rules, ML features, collection decisions, or user-facing product features that consume it.

The practical conclusion: from the user’s perspective, Liabilities has no visible effect on their experience at all. It is ingested passively when Plaid pushes changes, stored in DynamoDB, and never surfaced in any decision or display that affects the user. The infrastructure is built and waiting, but the product use case has not been implemented yet.

Billing implication: Because Liabilities is additional_consented_products in the link token, it is only billed when /liabilities/get is first called for an Item — which only happens when the EnableLiabilityMining flag is on and a LIABILITIES webhook fires. If the flag has ever been on in production, some Items may already have Liabilities initialized and be paying the monthly subscription fee for data that serves no current business purpose. This is worth auditing — see Cost Reduction Opportunities.


Processor Tokens

A scoped token that lets a third-party processor initiate ACH debits without ever seeing raw account numbers.

Pricing: Not publicly listed. Negotiated per processor partner — typically a per-token creation fee or bundled with Auth pricing.

A processor token lets a payment processor like Array initiate ACH debits on a connected account without receiving the raw account and routing numbers. FloatMe calls /processor/token/create with an access_token and account_id; Plaid returns a token scoped exclusively to Array. Array uses that token to pull repayment funds. Bank credentials never leave Plaid — Array only ever holds an opaque token it can use with Plaid’s processor API. The token is stored in DynamoDB under the Item record, prefixed with plaid.<institution_id>.

Note: Processor Tokens are often confused with TANs (Tokenized Account Numbers). They are different technologies. TANs are bank-issued virtual account numbers; Processor Tokens are Plaid-issued API access credentials. See the TANs vs Processor Tokens — side by side section.

Services

transactions-service (creates); entitlements-service (consumes)

Endpoints

/processor/token/create

Processor

Array (bank-as-a-service)

Storage

DynamoDB prod-txn-plaid, field processor_tokens[]

Notes

Deduplicated — cached token returned if one already exists for the account+processor pair. 409 from Array handled gracefully.

At FloatMe — what Array is: Array is a credit-building platform. FloatMe offers it as part of its Premium membership tier, providing users with credit score monitoring, rent reporting, and other credit-building features. Array requires access to the user’s bank account data via the processor token to perform these services.

At FloatMe — when Processor Tokens are created:

There are two distinct moments when a Processor Token is created or refreshed:

Moment 1 — Array onboarding (first-time Premium enrollment). When a user upgrades to Premium, ArrayOnboardingScreen is opened — either from the main dashboard via ArrayOnboardingWidget, after a Premium membership upgrade via the membership_welcome_widget, or via deep link. The screen renders Array’s enrollment UI in a WebView. When the user completes Array’s signup flow, Array fires a JavaScript event (tagName == 'array-account-enroll', event == 'signup') with an Array user ID. The app responds by calling entitlements-service LinkArrayAccount(), which triggers the Processor Token creation and links it to the Array user account. This is the moment the Plaid Processor Token is first created for that user.

Moment 2 — Primary bank account change (existing Premium users). Because Array links at the processor token level — the token is tied to a specific account at a specific institution — changing the user’s primary bank account invalidates the existing Processor Token for Array. The relinkArrayProcessorToken() function in ConnectBankWidget and change_plaid_acc_vm.dart is called automatically whenever the primary account is updated. It only fires if the user is Premium AND has a successful Array onboarding status (arrayValidationState == ArrayValidationState.Success). This creates a new Processor Token for the new account and re-links it with Array.

How it works technically: entitlements-service calls transactions-service at POST /{user_id}/transactions/items/{item_id}/accounts/{account_id}/processor-token with { "processor": "array" }. transactions-service checks for an existing token first (item.FilterProcessorTokens()) — if one exists for this account+processor pair, it returns the cached token. Otherwise it calls Plaid’s /processor/token/create, stores the result in the Item’s processor_tokens[] array in DynamoDB, and returns it. entitlements-service then passes the token to Array’s LinkProcessorToken() API with the institution ID prefixed as "plaid.<institution_id>". If Array returns 409 (already linked), the existing LinkId is retrieved instead. The LinkProcessorTokenID is stored in the user’s ArrayEntitlement config in DynamoDB.


Identity Verification

Plaid’s hosted KYC flow — identity document collection, liveness check, and compliance gating for the Line of Credit product.

Pricing: Per session. Each call to /identity_verification/create is a billable event, regardless of outcome. Pricing is negotiated — not publicly listed.

Identity Verification (IDV) is Plaid’s fully managed Know Your Customer flow. It collects a government-issued ID (passport or driver’s license), a real-time selfie for liveness detection, SMS ownership verification, and a suite of automated checks: document authenticity, identity data cross-reference, risk scoring, and watchlist screening (see the dedicated Watchlist section below). All of this runs inside a Plaid-hosted modal — FloatMe never handles document images or ID data directly. The session result is returned as a structured pass/fail with step-level detail for each check. Sessions are idempotent: retrying with the same user returns the existing session rather than creating a new billable one.

IDV is not part of the main float onboarding. It is exclusively used to gate the LOC (Line of Credit / Prosper installment loan) product — the only product at FloatMe that requires identity verification before funds can be issued.

Services

transactions-service, floatme-flutter

Endpoints

/identity_verification/create, /identity_verification/get

Key files

pkg/api/kyc.go, pkg/plaid/plaid.go (lines 771–949), pkg/miner/kyc.go, pkg/dynamo/identity_verification.go

Notes

IsIdempotent: true on session creation. Failure creates a REJECTED loan application in loc-service and fires an Iterable marketing event. Users are allowed up to 2 retry attempts before a permanent block screen.

At FloatMe — when IDV is triggered:

IDV is invoked at one specific moment: when a user in the LOC onboarding flow taps "Verify my identity" on loc_onboarding_kyc_screen.dart. This screen is only reachable from the Prosper/LOC onboarding path — it is not part of signup, reactivation, or any other float flow.

Step 1 — Session creation. The app calls POST /{user_id}/user/kyc with the user’s name, address, email, phone, and a Plaid template ID. transactions-service calls IdentityVerificationCreate with IsIdempotent: true, receives a session ID (idv_xxx) and an associated watchlist screening ID, and stores the session in DynamoDB indexed three ways: by user ID, by created timestamp (for GetLatest), and by watchlist screening ID (for webhook routing). Status is set to active.

Step 2 — Link token generation. The user taps "Continue verification." The app calls POST /{user_id}/user/kyc/token, which retrieves the latest IDV record and generates a Plaid Link token scoped to PRODUCTS_IDENTITY_VERIFICATION. The Link token is returned to the app.

Step 3 — In-app KYC flow. Plaid Link opens with the user’s data pre-filled. The user steps through: accept terms (accept_tos), verify SMS (verify_sms), identity check (kyc_check), government ID upload (documentary_verification), selfie (selfie_check), watchlist screening (watchlist_screening), and risk check (risk_check). FloatMe never receives or stores the images.

Step 4 — Completion. When the user exits Plaid Link, the app calls POST /{user_id}/user/kyc/completed, transitioning the session from active to processing (unless a webhook has already moved it further).

Webhook handling. The miner Lambda processes IDENTITY_VERIFICATION_STATUS_UPDATED webhooks and routes by the resulting status:

  • successhandleKycSuccess() fires. User is shown a success screen and the "Continue" button enables them to proceed to the loan application. (The handler currently logs and returns — business logic for automatic approval is not wired yet.)

  • failedhandleKycFailed() fires. loc-service is called to create a REJECTED loan application. An Iterable marketing event is fired. User sees a failure screen. They may retry up to 2 times; after 2 failures, LocKycFailureLimitScreen is shown — a permanent block.

  • expired — session timed out before completion; user must start a new session.

  • canceled — user exited the modal; retry is available.

  • pending_review / processing — awaiting Plaid’s automated checks or manual review. The app polls on a 10-second interval and shows a countdown UI.

Data stored in DynamoDB (prod-txn-transactions table, partition key USERS#{user_id}, sort key IDENTITY_VERIFICATION#{idv_id}): session ID, user ID, template ID, watchlist screening ID, KYC status, watchlist status, timestamps (created, updated, completed), and step-level statuses for each of the 7 check steps.


Watchlist Screening

AML/OFAC compliance screening — embedded inside every IDV session, not a standalone flow.

Pricing: Per screened user (one-time at session creation, covered by the IDV session fee) plus a recurring monthly monitoring fee per active user while Plaid continues to watch for sanctions list changes. Pricing is negotiated — not publicly listed.

Watchlist Screening (also called Plaid Monitor) checks a user against OFAC sanctions lists, PEP (Politically Exposed Persons) databases, and adverse media sources. It runs automatically inside every IDV session — FloatMe does not trigger it separately. When Plaid detects a change to a monitored list after the initial session (e.g., a user is added to a sanctions list weeks later), Plaid fires a new SCREENING webhook with an updated status. This is the "continuous monitoring" component that incurs the ongoing monthly fee.

The critical distinction from IDV: the two statuses are tracked independently. A user can pass IDV but still be in pending_review on watchlist screening, or vice versa. Both must reach a resolved state before the LOC product can move forward.

Services

transactions-service

Endpoints

/watchlist_screening/individual/get

Key files

pkg/miner/kyc.go (lines 264–394), pkg/plaid/plaid.go (lines 950–968), pkg/dynamo/identity_verification.go

Notes

Screening ID is returned as part of the IDV session creation response. DynamoDB record is shared with IDV — separate idv_watchlist_status field. GSI on watchlist ID enables webhook routing without knowing the user ID.

At FloatMe — how Watchlist Screening works:

Watchlist Screening is not user-visible as a separate step. From the user’s perspective, it is one checkbox inside the IDV flow (watchlist_screening step). Plaid runs it in the background. FloatMe only interacts with it via webhooks.

Initial screen. When the IDV session is created (/identity_verification/create), Plaid returns a watchlist_screening_id in the response. This ID is stored in the DynamoDB IDV record (idv_watchlist_id). No separate API call is needed — the screen is already running.

Webhook routing. The miner Lambda handles SCREENING type webhooks with STATUS_UPDATED code in handleScreeningWebhook() (pkg/miner/kyc.go). Because the webhook contains the watchlist screening ID but not the user ID, a DynamoDB GSI lookup by screening ID (GSI2: USERS#{user_id} + IDENTITY_VERIFICATION#{watchlist_id}) is used to find the right record.

Status outcomes:

  • clearedhandleScreeningCleared() fires. User passes AML check. (Currently logs — no downstream action wired yet.)

  • pending_reviewhandleScreeningPendingReview() fires. Case queued for manual review by FloatMe’s compliance team or Plaid’s review team. (Currently logs.) User sees the 10-second polling UI in the app.

  • rejectedhandleScreeningRejected() fires. User matched a sanctions list or PEP entry. (Currently logs — no automatic downstream action beyond updating the DynamoDB status field.) This is a compliance block.

Ongoing monitoring. After clearing, Plaid continues to watch the screened users against list updates. If a previously cleared user is subsequently matched (e.g., newly sanctioned), Plaid fires a new SCREENING webhook with rejected or pending_review status. FloatMe’s miner handles this identically to the initial screening webhook — the idv_watchlist_status field in DynamoDB is updated. There is currently no automated business action triggered by a post-clearance rejection (the handlers log only).

Data stored: idv_watchlist_status (processing | cleared | pending_review | rejected) and idv_watchlist_id, stored on the same DynamoDB record as the IDV session.

Cost note: Every user who completes IDV (successfully or not) has a Watchlist Screening record created and potentially enters ongoing monitoring. The monthly monitoring fee accumulates per active monitored user — distinct from the per-session IDV charge. The number of users in active monitoring is auditable by counting distinct idv_watchlist_id values with a non-terminal status in prod-txn-transactions.


Layer

Frictionless pre-signup bank linking — lets users connect their bank account and pre-fill personal data before a FloatMe account exists.

Pricing: Not separately listed. Layer pricing is typically bundled with the Transactions product for Items created via Layer, subject to the same per-Item subscription model as standard Link. Requires a sales conversation.

Layer is a Plaid product that inverts the usual signup sequence. In the standard flow, a user creates an account and then links their bank. With Layer, the user links their bank first — before a FloatMe account exists — and Plaid extracts identity data (name, address) from the bank connection to pre-fill the signup form. For users who have previously authenticated with any institution through Plaid (anywhere, not just FloatMe), Layer can match on phone number and skip the institution-select and credential-entry screens entirely, completing the entire bank link with just an OTP. For users Plaid doesn’t recognize, Layer falls back to standard Link.

Layer is gated behind a GrowthBook feature flag (plaidLayer, default false) and only shown during the initial signup flow. It is not available at relink, account change, or any other post-signup flow.

Services

transactions-service, floatme-flutter

Endpoints

/session/token/create, /user_account/session/get

Key files

pkg/api/link.go (lines 143–226), pkg/dynamo/layer_stub.go, pkg/plaid/plaid.go (lines 507–551); Flutter: mfa_signup_page.dart, plaid_link_init_service.dart, plaid_link_service.dart

Notes

Uses a random UUID for the Plaid client user ID — the real FloatMe user account does not exist yet at this point. LayerStub has a 7-day DynamoDB TTL. Android requires a 2-second delay before SDK initialization. The feeder Lambda removes orphaned LayerStub Items (user never completed signup) via /item/remove.

At FloatMe — when Layer is shown and how it works:

Layer is invoked at one specific moment: during new user signup, on mfa_signup_page.dart, before the user has created a FloatMe account. It is a fast-path alternative to both entering personal info manually and going through the standard post-signup bank link.

Step 1 — Feature flag check. On mfa_signup_page.dart, the app checks FFHandler.isEnabledGrowthbook(flag: FeatureFlags.plaidLayer). If false, Layer is skipped entirely and the standard MFA/signup flow runs. If true, Layer is attempted.

Step 2 — Session token creation. The app calls GET /transactions/layer/session on transactions-service. Because no FloatMe user exists yet, transactions-service uses a randomly generated UUID as the Plaid client user ID — not tied to any real account. It calls /session/token/create and returns the session token to the app.

Step 3 — Layer initialization. In plaid_link_init_service.dart, initializePlaidLayer() calls setupPlaidLayerConfig(plaidSessionToken, userPhoneNumber). The SDK is initialized with the session token and the user’s phone number. On Android, a hardcoded 2-second delay is inserted before the SDK is ready (known Android initialization timing issue). Plaid then calls PlaidLink.submit(SubmissionData(phoneNumber: userPhoneNumber)), which signals Layer to attempt phone-number-based matching.

Step 4 — 12-second timeout window. A 12-second timer begins. During this window:

  • If Layer recognizes the phone number and the user responds to the OTP: LAYER_READY event fires → the bank selection modal opens and the user completes linking.

  • If Layer cannot match the phone number, or the user is new to Plaid: LAYER_NOT_AVAILABLE fires.

  • If the timer expires before either event: timeout → fallback to standard MFA flow (manual entry of all personal info).

Step 5 — Layer success. On successful bank link, Plaid returns a public_token along with identity data: first name, last name, street address (lines 1 and 2), city, state, and zip. The app POSTs the public_token to POST /layer/account/session on transactions-service, which calls /user_account/session/get to retrieve the Items and access tokens associated with the session. These are stored in a LayerStub in DynamoDB — a temporary record keyed by the public token, containing the item_id / access_token pairs, with a 7-day TTL. The identity data is returned to the app and used to pre-fill the signup form fields.

Step 6 — User completes signup. The user reviews the pre-filled fields (editable), then proceeds with signup normally. The flag isPlaidLayerComplete = true is set, and layerSubmissionLoading is active to prevent back-navigation while the account is being created.

Step 7 — LayerStub promotion. After the FloatMe account is created, CreateItemsFromLayerStub() is called. It looks up the LayerStub by public token, retrieves the stored access tokens, and creates permanent Item records in DynamoDB — exactly as if the user had gone through standard Link post-signup. The LayerStub is consumed and the user now has a linked bank account.

Failure / fallback path. Any of the following fall through to the standard MFA flow: timeout after 12 seconds, LAYER_NOT_AVAILABLE event, user exits the modal, institution not supported. In these cases, the user enters personal info manually and links their bank through the standard post-signup Link flow. Layer failure does not block signup.

Orphaned stub cleanup. If a user completes Layer (Step 5) but never finishes signup, the LayerStub in DynamoDB has a 7-day TTL. When that TTL fires, the DynamoDB Streams feeder Lambda detects the expired record and calls /item/remove for any Items created for the stub session that have no associated FloatMe user — cleaning up both the Plaid Item and stopping any incipient Transactions subscription.


Institutions

Metadata about supported banks — logos, names, product support, OAuth status.

Pricing: Free. /institutions/get and /institutions/get_by_id are not billed.

Returns metadata about any financial institution Plaid supports: name, logo, primary color, supported products, and whether the institution uses OAuth. FloatMe uses this to display institution branding and to cache institution details locally. Institution IDs from this API are also hardcoded into underwriting rules for Chime/Varo-specific balance thresholds (ins_35, ins_115640, ins_129229, ins_132289).

Services

transactions-service, underwriting-service, backoffice

Endpoints

/institutions/get, /institutions/get_by_id

Notes

Weekly sync every Sunday 00:00 UTC. Chime/Varo institution IDs hardcoded in underwriting rule — verify these IDs are still current. Real-time health used in backoffice admin UI.

At FloatMe: The cmd/institution Lambda runs every Sunday at 00:00 UTC, paging through all US institutions in batches of 500 via /institutions/get and upserting each into DynamoDB prod-txn-plaid-institutions keyed by institution_id. Fields stored: name, products, routing numbers, base64-encoded PNG logo, primary color hex, OAuth flag, and DTC numbers. Individual failures are logged but non-fatal.

For real-time health, transactions-service exposes GET /{user_id}/transactions/institutions/{institution_id}, which calls /institutions/get_by_id with IncludeStatus: true and returns auth.breakdown.success and transactions_updates.breakdown.success. The backoffice renders a color-coded badge from these values: green (≥0.8), yellow (≥0.6), red (<0.6), with a 30-minute SWR cache.

Institution IDs are also hardcoded into underwriting. rule_institution_check_v1_1 in underwriting-service checks whether the user’s institution_id is in ["ins_35", "ins_115640", "ins_129229", "ins_132289"] (Chime and Varo). If so, both available_balance and current_balance must be ≥ 5 cents for approval. All other institutions pass this rule automatically.


Sandbox

A free, isolated test environment with controllable fake bank accounts.

Pricing: Free. All Sandbox calls are unmetered.

Plaid’s Sandbox is a fully isolated test environment with pre-seeded fake institutions and accounts. It exposes endpoints that don’t exist in production: /sandbox/item/reset_login forces an Item into ITEM_LOGIN_REQUIRED error state to test re-link flows, and /sandbox/item/fire_webhook triggers any webhook event type on demand without waiting for real bank activity. qa-automation uses these to set up deterministic test scenarios — specific balances, transaction histories, error states — before running integration tests.

Services

transactions-service, qa-automation; floatme-api (retiring)

Endpoints

/sandbox/item/reset_login, /sandbox/item/fire_webhook, /sandbox/public_token/create

Notes

qa-automation environment is hardcoded to Sandbox. Account matching uses MD5 hash of (amount, type, subtype). Three sandbox-only endpoints exist in transactions-service for direct state injection without going through Plaid.

At FloatMe: qa-automation is hardcoded to plaid.Sandbox and exposes an SigV4-authenticated REST API. Its primary endpoint POST /custom-integration-user orchestrates a complete test user in one call: creates an Auth0 user, calls SetupCustom() to create a Plaid item with specified accounts and transactions, saves the item to transactions-service, fires webhooks to trigger ingestion, adds debit cards, and creates a subscription.

Account config is passed as a structured override (type, subtype, starting balance, explicit transaction list) serialized into the Plaid sandbox "password" field. Accounts are matched back to the config after creation using an MD5 hash of (amount, type, subtype). Four core operations: Setup (standard Gingham connection), SetupCustom (custom balance + transactions), SetupInvalid (Setup + immediate ResetLogin), and Teardown. FireWebhook / FireLiabilitiesWebhook trigger DEFAULT_UPDATE events to drive the full webhook → miner → refiner pipeline.

transactions-service also exposes three sandbox-only endpoints for direct state injection without calling Plaid: POST /{user_id}/items/{item_id}/reset (triggers ResetLogin), POST /{user_id}/qa/add-balances (writes balance records directly to DynamoDB), and POST /{user_id}/qa/kyc (injects a KYC record with arbitrary status).


Cost Reduction Opportunities

What Plaid does NOT charge for — no opportunity here

The following have no cost and no reduction opportunity regardless of volume:

Category Free endpoints

Item management

/item/get, /item/remove, /item/webhook/update, /item/public_token/exchange

Link

/link/token/create (all link token creation)

Institutions

/institutions/get, /institutions/get_by_id

Sandbox

All sandbox endpoints

Webhooks

Receiving and processing Plaid webhooks

Items themselves are also free. Creating an Item, storing it, and keeping it active in Plaid incurs no charge on its own. An Item is just a connection record — it only becomes a cost when a subscription-based product is enabled on it.


What does cost money — the billing surface

Subscription products — monthly fee per Item, for as long as the Item exists with that product enabled:

Product Billing trigger Notes

Transactions

Item creation — billing starts immediately because Transactions is a required product in FloatMe’s link token

Every active Item is on the meter from the moment the user completes Link

Liabilities

First /liabilities/get call — because it’s additional_consented_products, not required

An Item can exist indefinitely without Liabilities billing if the EnableLiabilityMining flag is off or no webhook fires

One-time products — charged once per Item, on first use:

Product Billing trigger

Auth

First /auth/get call on a given Item

Per-request products — charged per successful API call:

Product Billing trigger

Balances

Every /accounts/balance/get call

Transactions Refresh

Every /transactions/refresh call

Processor Tokens

Negotiated — per /processor/token/create call or bundled

Identity Verification

Per KYC session created

Watchlist Screening

Per user screened + monthly monitoring fee per active user

Summary: The dominant recurring cost driver is Transactions × active Item count. Everything else is either free, one-time, or per-call. Reducing active Item count is the highest-leverage cost reduction action available.


1. Dormant Item Cleanup — the primary lever

Every active Item costs a monthly Transactions subscription fee regardless of usage. The most direct cost reduction available is removing Items for users who are no longer active. /item/remove is a free API call; the only cost is the trade-off described below.

The trade-off: /item/remove permanently invalidates the access token. If a dormant user returns, they must go through the full Plaid Link flow again — re-authenticating with their bank and completing MFA. That re-auth friction reduces reactivation conversion. Additionally, the re-linked Item starts with no transaction history; previously mined data in DynamoDB is retained but the new Item has no context, which could affect underwriting quality on the first post-return float request.

The break-even question to model:

Keep Item active Remove Item

Monthly cost while dormant

$X/month × months dormant

$0

User returns

Instant re-activation, no friction

Full Link re-auth required, ~Y% drop-off

Transaction history on return

Intact, immediately usable

Lost for new Item — re-mine required

Underwriting on return

Full history

Starts fresh, potentially lower approval rate

The right dormancy threshold depends on FloatMe’s reactivation rate and the revenue value of a returning user. Common approaches:

  • Tiered by recency — keep Items active for 90–180 days of dormancy (covers seasonal users), then remove

  • Tiered by value — keep Items for users who ever took a float longer than users who linked but never engaged

  • Flat cutoff — remove all Items after N months of no product activity

The dbt-floatme cost models and user activity data in the warehouse are the right tools to run this analysis.

First step: Audit the gap between active Item count in prod-txn-plaid and genuinely active user count. Any Item where the associated user has been churned or deactivated for more than your chosen threshold is pure wasted spend.


2. LOC onboarding Items may not need Transactions — a real saving

The LOC (Line of Credit) onboarding flow (loc_onboarding_viewmodel.dart) creates a Plaid Item independently from the main float onboarding. If this flow’s primary need is Auth (routing/account numbers for payments) rather than transaction history, those Items are being billed for Transactions unnecessarily.

The opportunity: If the LOC product does not require transaction data, moving Transactions from products (required) to additional_consented_products specifically for the LOC link token — or using a separate link token configuration for LOC that omits Transactions entirely — would mean those Items are never initialized with Transactions and never billed for it.

What to verify: Whether the LOC underwriting or payment flow ever calls /transactions/get on LOC Items. If it does not, every LOC Item currently carries a monthly Transactions charge it doesn’t need. This requires confirming with the team that owns the Prosper/LOC product.


3. Liabilities may be generating subscription charges for data that is never used

Liabilities is configured as additional_consented_products — meaning billing only triggers when /liabilities/get is first called for an Item. That only happens when the EnableLiabilityMining feature flag is on and Plaid fires a LIABILITIES / DEFAULT_UPDATE webhook.

Searching the entire codebase, liability data is not consumed by any production business logic. Underwriting explicitly excludes it (IncludeLiabilities: false). The only read path is a backoffice admin endpoint used in the QA section of the admin tool. There are no user-facing features, no collection decisions, and no ML features that use it.

If EnableLiabilityMining has ever been enabled in production, some Items will already have Liabilities initialized — meaning those Items are now paying a monthly Liabilities subscription fee in addition to the Transactions fee, for data that is stored in DynamoDB and never read by anything consequential.

What to check:

  1. Is EnableLiabilityMining currently enabled in production?

  2. How many Items in prod-txn-plaid have liabilities in their billed_products[] array?

  3. If the answer to #2 is non-zero, every one of those Items is paying for a product nobody is using.

The fix: Ensure EnableLiabilityMining is off until there is an active use case for the data. If Items already have Liabilities initialized, the only way to stop the subscription is /item/remove — there is no way to de-initialize a single product from an Item.


Options considered for cost reduction that did not work

These approaches were evaluated and ruled out. Documented here to avoid re-exploring dead ends.

"There is no pause — stop calling APIs for dormant users to pause billing" — Does not work. Plaid’s subscription billing is not tied to API call volume. Plaid’s docs are explicit: "If an Item’s subscription is active, Plaid will charge for the subscription even if no API calls are made for the Item." Once Transactions is initialized on an Item, the monthly subscription runs regardless of API call volume. Stopping calls saves on per-request costs (Balances) but has no effect on the Transactions subscription. There is also no API to remove a single product from an Item while keeping the Item alive — the only granularity Plaid offers is the entire Item. The billing controls are binary:

Action Effect

User completes Link → public_token exchanged

Item created, Transactions subscription starts

/item/remove called

Item destroyed, Transactions subscription stops

Calling or not calling /transactions/get

No effect on subscription billing

User goes dormant, you stop fetching their data

No effect on subscription billing

"Use additional_consented_products to turn Transactions on/off per user" — Does not work as an on/off switch. additional_consented_products only controls when billing starts — it defers the first charge until the first API call rather than billing at Item creation. But once that first call is made and Transactions is initialized on the Item, it becomes a permanent monthly subscription. There is no mechanism to de-initialize a product from an Item short of removing the entire Item. You cannot pause a subscription for a specific user for a month and resume it later.

"Remove just the Transactions product from an Item while keeping Auth active" — Does not work. Plaid has no API endpoint to remove a single product from an Item. The only granularity is the entire Item via /item/remove.


SDK Dependency Relationship

fmsdk is a Go monorepo where each subdirectory is a separate versioned module. The relevant ones here:

  • fmsdk/clients — contains both plaid/ (a direct Plaid API wrapper) and txns/ (an HTTP client for transactions-service). Services that import fmsdk/clients get plaid-go v15 in their module graph, but whether they actually call Plaid depends on which package they use.

  • fmsdk/clients/txns — calls transactions-service over HTTP. Using this does not result in any Plaid API calls from the service itself.

Repo SDK in go.mod/pubspec Plaid import in source? What they actually do with Plaid

transactions-service

plaid-go v32.1.0 (direct)

Yes — plaid-go/v32/plaid

Own Plaid client in pkg/plaid/; 22+ API calls, webhooks, full data model

floatme-api ⚠️ retiring

plaid-go v0 (2021) (direct)

Yes — plaid-go

Own Plaid client; legacy webhook handler, account linking — service being retired; Plaid infra still deployed

payments-service

plaid-go v25.0.0 (direct)

Yes — plaid-go/v25/plaid

Own hand-rolled Plaid client in pkg/plaid/; does not use fmsdk

credit-card-service ⚠️ archiving

plaid-go v15.3.0 (direct)

Yes — plaid-go/v15/plaid (tests + prod)

Uses fmsdk plaid wrapper; imports Plaid types directly in tests — to be archived

insight-service ⚠️ uncertain

plaid-go v15.3.0 (direct)

Yes — plaid-go/v15/plaid (prod code)

Imports Plaid types directly to wrap TransactionsGetResponse, AccountsGetResponsemay be stale; confirm feeder Lambda is still active

qa-automation

plaid-go v8.2.1 (direct)

Yes — plaid-go/v8/plaid

Own Plaid client; sandbox API calls for test data setup

fmsdk

plaid-go v15.3.0 (direct)

Yes — plaid-go/v15/plaid

The shared Plaid wrapper. clients/plaid/ implements NewClient(), GetTransactions(), GetAuth(), etc.

floatme-flutter

plaid_flutter 5.0.5 (direct)

Yes

Plaid Link UI, Layer MFA flow, event callbacks

user-service

plaid-go v15.3.0 (transitive via fmsdk/clients)

No — uses fmsdk/clients/txns only

Calls transactions-service HTTP API; no Plaid API calls

admin-api

plaid-go v15.3.0 (transitive via fmsdk/clients)

No — uses fmsdk/clients/txns only

Calls transactions-service HTTP API; no Plaid API calls

float-service

plaid-go v15.3.0 (transitive via fmsdk/clients)

No — uses fmsdk/clients/txns only

Calls transactions-service HTTP API; no Plaid API calls

underwriting-api

plaid-go v15.3.0 (transitive via fmsdk/clients)

No — uses fmsdk/clients/txns only

Calls transactions-service HTTP API; no Plaid API calls

entitlements-service

plaid-go v15.3.0 (transitive via fmsdk/clients)

No — uses fmsdk/clients/txns only

Calls transactions-service HTTP API; no Plaid API calls

subscription-service

plaid-go v15.3.0 (transitive via fmsdk/clients)

No — uses fmsdk/clients/txns only

Calls transactions-service HTTP API; no Plaid API calls

loc-service

plaid-go v15.3.0 (transitive via fmsdk/clients)

No — zero Plaid references in source

Calls transactions-service HTTP API; no Plaid interaction whatsoever

rewards-service

None (imports fmsdk/clients/user sub-module, not main fmsdk/clients)

No — zero Plaid references in source

No Plaid interaction

backoffice

None

No

Displays Plaid data fetched via admin-api REST calls

underwriting-service

None

No

Python Lambdas; evaluates pre-fetched Plaid data passed as input

ollie

None

No

Zendesk ticket tagging only (plaid tag string)

data-engineering

None

No

Processes Plaid data from Snowflake/DynamoDB via Glue jobs

mwaa-airflow

None

No

Orchestrates Glue jobs; no Plaid API calls

dbt-floatme

None

No

SQL models over pre-ingested Plaid tables

data-warehouse

None

No

ETL over Plaid data in Snowflake

machine-learning

None

No

Uses 37 Plaid-derived features from warehouse as training data

infrastructure

None

No

Defines webhook gateway domains and IAM for Plaid webhooks

documentation

None

No

System diagrams reference Plaid as external service

Summary: 8 repos import and call the Plaid SDK directly in source code. 6 Go services have plaid-go in their module graph transitively (via fmsdk/clients) but only call transactions-service over HTTP — they never invoke Plaid. The remaining repos reference Plaid only in data, config, docs, or string literals.


Service Map

Service SDK Version Direct Plaid Calls Products Webhooks Notes

transactions-service

plaid-go v32.1.0

Yes — 22+ endpoints

Link, Auth, Txns, Balances, Liabilities, Institutions, Identity Verification, Watchlist, Processor Tokens

Yes — full JWT verification

Core hub. All other services depend on it.

floatme-api ⚠️ retiring

plaid-go v0 (2021)

Yes — 8+ endpoints

Auth, Transactions

Yes — webhook-handler Lambda

Service being retired. Plaid webhook handler, PostgreSQL access token storage, and legacy client still deployed. Needs decommission.

user-service

plaid-go v15.3.0 (transitive)

No — calls transactions-service via fmsdk/clients/txns

Link, Auth, Txns, Liabilities (via txns-service)

Membership revocation event only

All Plaid operations delegated to transactions-service

credit-card-service ⚠️ archiving

plaid-go v15.3.0 (via fmsdk)

Yes — 1 endpoint

Auth

No

To be archived. /auth/get only; fetches routing/account numbers for ACH.

payments-service

plaid-go v25.0.0

Yes — 2 endpoints

Transactions, Auth

No

Hand-rolled pkg/plaid/ client (does not use fmsdk); used by check-ach-worker Lambda

fmsdk

plaid-go v15.3.0

Yes (shared lib)

Auth, Transactions, Balances, Institutions

No

Shared client used by user-service, credit-card-service, underwriting-api

floatme-flutter

plaid_flutter 5.0.5

No (Link UI only)

Link, Layer

No

Standard Link + Layer (MFA) flows; institution blocking (Varo, Chime)

insight-service ⚠️ uncertain

plaid-go v15.3.0 (direct)

Yes — wraps Plaid types

Transactions

Kinesis consumer

Consumes Plaid txn events from Kinesis; feeds Pave service. May contain stale code — confirm feeder Lambda is still active.

qa-automation

plaid-go v8.2.1

Yes

Auth, Txns, Liabilities, Sandbox

No

Uses sandbox API to generate test data

admin-api

None (via fmsdk wrapper)

No (delegates to txns-service)

Auth, Txns, Liabilities

No

Admin read layer; no own Plaid calls

float-service

None

No (via txns-service API)

Balances

No

Reads account balances for repayment collection decisions

underwriting-api

None (via txns-service API)

No

Transactions

No

Analyzes Plaid txn categories for underwriting rules

underwriting-service

None

No

Balances, Institutions

No

Python Lambdas; institution-specific rules (Chime/Varo IDs: ins_35, ins_115640, ins_129229, ins_132289)

entitlements-service

None

No

Processor Tokens (indirect)

No

Uses Plaid processor tokens (prefixed plaid.) for Array ACH integration

subscription-service

None

No

Auth (indirect)

Balance webhooks (indirect)

Checks item health before prenote submission

machine-learning

None

No

No

37 Plaid-derived features feed CatBoost underwriting model

data-engineering

None

No

No

Glue Spark jobs; bronze (S3 Parquet) → silver (Redshift) pipeline

mwaa-airflow

None

No

No

Airflow DAGs orchestrating Plaid bronze/silver/staging jobs (daily 9–10 AM UTC)

dbt-floatme

None

No

No

dbt models: staging, cost analytics, user revocation tracking

data-warehouse

None

No

No

Snowflake ETL; tracks removed_transactions and replaced_transactions

backoffice

None

No

No

React/TS admin UI; displays Plaid data via admin-api

ollie

None

No

No

Zendesk tagging only (plaid, bank, bank_account_change)

infrastructure

N/A

N/A

Webhook gateway

API Gateway domains: plaid.webhooks.floatme.io / plaid.webhooks.test.floatme.io

edge

None

No

No

No Plaid integration

fm-website

None

No

No

No Plaid integration


Webhook Infrastructure

Production Domains

  • plaid.webhooks.floatme.io (prod)

  • plaid.webhooks.test.floatme.io (test)

Infrastructure

  • AWS API Gateway v2 HTTP API (defined in infrastructure/aws/webhooks.tf)

  • CloudWatch logging on all requests

  • IAM execution role allows Lambda invocation from API Gateway

Webhook Receivers

transactions-service (current):

  • Endpoint: POST /v1/plaid (via cmd/webhook Lambda)

  • Verification: JWT (ES256) with JWK public key cache; body validated via SHA256 hash in JWT claim

  • Enqueues to SQS prod-txn-plaid-webhooks → miner Lambda

  • Throttle queue for high-volume events: prod-txn-throttle

  • Event types handled: TRANSACTIONS (DEFAULT_UPDATE, HISTORICAL_UPDATE, INITIAL_UPDATE, TRANSACTIONS_REMOVED), ITEM (WEBHOOK_UPDATE_ACKNOWLEDGED, LOGIN_REPAIRED, USER_PERMISSION_REVOKED, ERROR), AUTH (DEFAULT_UPDATE, STATUS_UPDATED), LIABILITIES (DEFAULT_UPDATE, REMOVED_LIABILITIES), IDENTITY_VERIFICATION, SCREENING

floatme-api ⚠️ retiring — webhook handler still deployed, needs decommissioning:

  • Verification: ECDSA JWT from Plaid-Verification header

  • Sends to primary + secondary SQS queues (dual-write during migration)

  • Deduplication: DynamoDB-backed within 1-hour window per item_id + webhook_code

  • Always returns 200 to Plaid; failures logged but don’t block delivery

  • Event types: TRANSACTIONS (DEFAULT_UPDATE, TRANSACTIONS_REMOVED)


Item Lifecycle & /item Endpoint Usage

A Plaid Item is a bank connection. Its lifecycle directly controls Transactions subscription billing — each active Item incurs a monthly charge, which stops only when the Item is removed. All /item/* management endpoints are *free to call* (not billed).

Endpoints Used

Endpoint When called Billing effect

/item/get

By miner Lambda on every webhook, to refresh AvailableProducts / BilledProducts

Free

/item/webhook/update

On every item creation (postItemSaveActions) to register FloatMe’s webhook URL

Free

/item/remove

See triggers below

Free — stops the monthly Transactions subscription

/item/remove Triggers

FloatMe calls /item/remove in four situations:

  1. User-initiated disconnect — app calls RemoveItemV2 (current) or RemoveItem (deprecated). Checks for active float before allowing removal.

  2. USER_PERMISSION_REVOKED webhook — user revokes access from their bank’s own settings. Miner Lambda (pkg/miner/item.go) handles this automatically.

  3. Item TTL expiry — the feeder Lambda (pkg/feeder/feeder.go) monitors DynamoDB TTL events. When an Item’s TTL field is reached, the feeder calls /item/remove with reason "item expired, user did not complete reactivation".

  4. Layer stub expiry — if a user begins signup via Plaid Layer but never completes it, the feeder detects the orphaned Item (no matching FloatMe user account) and removes it.

There is also ForceRemoveItem — an admin-only endpoint not exposed to the app, used by the backoffice to forcibly disconnect an item without float checks.

Open Question: Churned User Item Cleanup

The TTL mechanism (#3 above) covers users who start reactivation but don’t complete it. What is less clear from the code is whether Items belonging to churned or deactivated users have their TTL set, ensuring they are eventually removed and billing stops. If user churn/deactivation does not reliably set an Item TTL, those Items will persist indefinitely on the monthly subscription. The dbt-floatme/int_finance__plaid_costs.sql cost model and int_utilities__user_plaid_revoked.sql are the best starting points for auditing active Item counts against expected active user counts.


Item Health Management

A Plaid Item can become unusable in two distinct ways: the user’s credentials change (login required) or Plaid quietly expires the Item’s consent window over time. FloatMe handles the first well, does not handle the second at all, and has partial handling for a few related edge cases. This section documents what is implemented, what is not, and why it matters.


How Items break — two distinct causes

Cause 1 — Credential failure (reactive, user-visible). The user changes their bank password, the bank forces a re-authentication, or the institution initiates a migration to a new API or OAuth system. Plaid detects the failure and sends an ITEM / ERROR webhook with error_code: ITEM_LOGIN_REQUIRED. The Item immediately stops returning data.

Cause 2 — Consent expiration (silent, proactive). Plaid places a consent window on Items — the duration varies by institution but is typically 90 days to 1 year. As expiration approaches (approximately 30 days out), Plaid sends a PENDING_EXPIRATION webhook. If FloatMe does not prompt the user to refresh consent before the window closes, Plaid silently disables the Item — no error code, no bang. The Item stops returning data and eventually enters an error state, but the user received no warning from FloatMe.

These two causes require different responses: credential failures require the user to re-authenticate; consent expiration can sometimes be renewed silently (for OAuth institutions) or requires a re-link prompt well in advance.


What FloatMe handles: reactive error recovery

When an ITEM / ERROR webhook arrives, the miner Lambda (pkg/miner/item.go) calls updateItemStatus(), which fetches the Item from Plaid via /item/get, updates the DynamoDB record (status = ERROR, error_code = ITEM_LOGIN_REQUIRED), and fires a plaid_item_errored notification event to the user.

In the Flutter app, the Connected Accounts and Profile screens call getPlaidError() on each displayed item. If the item has an error code, an error widget renders: "We lost access to [Bank] — Reconnect to get updated balance information." with a "Reconnect" button. Tapping it opens Plaid Link in update mode — the existing access_token is passed to Plaid so the user only needs to re-enter changed credentials, not re-select their institution. On success, the token is exchanged and the item status returns to ACTIVE.

The LOGIN_REPAIRED webhook (Plaid fires this when the user fixes credentials on their bank’s own side) is also handled: the miner sends a plaid_item_repaired notification and syncs item status.

Error categories the app distinguishes:

Error type App behavior

ITEM_LOGIN_REQUIRED, INSUFFICIENT_CREDENTIALS

Update mode re-auth (requiresPlaidReauth)

NO_ACCOUNTS, ITEM_NOT_FOUND, NO_AUTH_ACCOUNTS

Fresh Link (account change required)

PRODUCT_NOT_READY, USER_SETUP_REQUIRED, ITEM_LOCKED, PASSWORD_RESET_REQUIRED

"Loading your accounts" — no action button

DynamoDB fields tracked on items:

  • item_statusACTIVE / ERROR / REMOVED

  • item_error_code — the specific Plaid error string

  • item_last_successful_transactions_update — timestamp of last good data

  • item_last_failed_transactions_update — timestamp of last failure

  • item_last_webhook_sent_at / item_last_webhook_code_sent — audit trail


PENDING_EXPIRATION webhook is silently ignored. Plaid sends this webhook ~30 days before an Item’s consent window closes. In pkg/plaid/webhook.go, the code parses the webhook type and routes by code — PENDING_EXPIRATION hits the default case, logs "unhandled webhook code", and returns nil. No notification is sent to the user. No field is set on the item record. No action is taken.

consent_expiration_time is not stored. The Plaid API returns this field on Item objects (it is defined in the fmsdk admin API spec). FloatMe’s DynamoDB item model does not include it, so even if the code were updated to handle PENDING_EXPIRATION, there is no stored timestamp to act on.

No scheduled proactive health check exists. There is a weekly cmd/institution Lambda, but it only fetches institution metadata — it does not iterate over Items, call /item/get, or check whether any Items are approaching expiration or have been silently disabled. There is no job that asks "which of our Items has not successfully refreshed in the last N days?"

The practical consequence: When an Item expires, the user’s transaction data silently stops updating. The next downstream failure that surfaces this to the user is likely a failed subscription collection or a stale balance read — not a clear "your bank connection expired" message.


Institution-forced breaks (migrations and API upgrades)

Banks periodically force all users to re-authenticate via Plaid — typically when they migrate from credential-based Plaid access to OAuth, when they upgrade their API, or when they require users to re-agree to updated terms. From FloatMe’s perspective, these arrive as ITEM / ERROR webhooks with ITEM_LOGIN_REQUIRED and are handled identically to a user changing their password.

There is no special handling that distinguishes a bank-wide forced migration from a single-user credential failure. In practice this means:

  • If Chase forces all Chase users to re-link via OAuth on the same day, FloatMe processes each as an individual ITEM_LOGIN_REQUIRED error and sends each affected user a notification

  • The Reconnect button opens Plaid Link in update mode, which handles OAuth re-auth correctly since Plaid manages the OAuth redirect

  • There is no logic to detect a "mass event" (many items at the same institution erroring simultaneously), send a proactive communication, or display any different messaging to affected users


Payments and broken items — a gap

payments-service does not validate Item status before initiating an ACH debit. The DynamoDB auth cache (routing number + account number) is populated on first use and updated only on a bank account change. If an Item enters ERROR state after auth was cached, the cached credentials remain and a collection attempt will proceed — the failure surfaces as an ACH return code (R04) rather than as "your bank connection is broken." The user receives a generic payment failure notification, not a re-link prompt.


Summary: implemented vs. not

Behavior Status Notes

ITEM / ERROR webhook → item status update

✅ Handled

pkg/miner/item.go, updateItemStatus()

LOGIN_REPAIRED webhook → notification

✅ Handled

plaid_item_repaired notification event

USER_PERMISSION_REVOKED webhook → item removal

✅ Handled

Token revoked, item removed from DynamoDB

Error widget + Reconnect button in app

✅ Handled

Update mode for ITEM_LOGIN_REQUIRED; fresh Link for account errors

PENDING_EXPIRATION webhook

❌ Not handled

Silently ignored; no notification, no field set

consent_expiration_time storage

❌ Not stored

Field not in DynamoDB item model

Scheduled item health check

❌ Not implemented

No Lambda iterates items or calls /item/get proactively

Proactive consent renewal prompt

❌ Not implemented

No advance warning before Items expire

Institution-wide migration detection

❌ Not implemented

Mass events treated same as individual failures

Payment blocking on broken item

❌ Not implemented

ACH proceeds on cached auth; fails at bank

Repeat / escalating notifications

❌ Not implemented

Single notification on error; no follow-up


Data Storage

DynamoDB (transactions-service)

Table Key Data

prod-txn-plaid

Items: item_id, access_token, user_id, main_account_id, institution_id/name, account_ids[], processor_tokens[], status, error_code, billed_products[], webhook timestamps

prod-txn-transactions

Full transaction data synced from Plaid

prod-txn-plaid-institutions

Institution metadata cache

Additional tables: accounts, liabilities, identity verification records

PostgreSQL (floatme-api — legacy)

  • FloatMeAPI_useraccountsmodel: stores account_id, access_token, public_token, user_id

  • Access tokens stored here — verify encryption at rest

Data Warehouse Pipeline

Both pipelines run concurrently during the active Snowflake → Redshift migration. Snowflake is in maintenance mode; Redshift is the active investment.


Snowflake pathway (legacy — maintenance mode)

Plaid API webhooks
    → Snowflake staging (snowflake_staging.plaid_transactions)
    → Snowflake data warehouse
        • floatme.plaid.transactions
        • floatme.plaid.accounts
        • floatme.plaid.removed_transactions
        • floatme.plaid.replaced_transactions
    → ML feature engineering (37 features)
    → CatBoost underwriting model (threshold: 0.55)

Source: data-warehouse/lake/ (DDL and COPY INTO pipes). Removed and replaced transaction records are currently tracked here and not yet replicated to the Redshift pathway.


Redshift pathway (active — in migration)

Three-layer architecture: Bronze (raw lake) → Silver (transformed lake) → Gold / dbt staging (native Redshift). All Glue jobs run on Glue 5.0 with FLEX execution class.

DynamoDB Streams (prod-txn-plaid, prod-txn-transactions, prod-txn-plaid-institutions)
    ↓
[Bronze Glue job: {env}-bronze-plaid] — 09:00 UTC daily (Airflow DAG: bronze_plaid)
    S3 Bronze (Iceberg/Parquet, partitioned by processing_date):
        s3://floatme-prod-redshift-data-lake/bronze/plaid/items/
        s3://floatme-prod-redshift-data-lake/bronze/plaid/transactions/
        s3://floatme-prod-redshift-data-lake/bronze/plaid/accounts/
    Glue Catalog: prod_redshift_bronze.{plaid_items, plaid_transactions, plaid_accounts}
    ↓
[Silver Glue jobs — 3 in parallel] — 10:00 UTC daily (Airflow DAG: silver_plaid)
    {env}-silver-plaid-transactions  (15 G.1X workers)
        → prod_redshift_silver.plaid_transactions
        → s3://floatme-prod-redshift-data-lake/silver/plaid/transactions/
        Transforms: UTF-8 sanitization, struct flattening (location, personal_finance_category),
                    counterparty[0] extraction, deduplication deferred to dbt
        59 output columns including pfc_primary, pfc_detailed, counterparty_*, location_*
    {env}-silver-plaid-accounts
        → prod_redshift_silver.plaid_accounts
        → s3://floatme-prod-redshift-data-lake/silver/plaid/accounts/
        17 output columns including available_balance, current_balance, mask, subtype
    {env}-silver-plaid-items
        → prod_redshift_silver.plaid_items
        → s3://floatme-prod-redshift-data-lake/silver/plaid/items/
        17 output columns including available_products, billed_products, error, institution_id
    {env}-silver-plaid-institutions
        → prod_redshift_silver.plaid_institutions
        Source: DynamoDB Streams from prod-txn-plaid (extracts institution fields from item records)
    ↓
[Redshift Sync DAG: redshift_sync_silver] — 11:00 UTC daily (PROD only)
    Discovers S3 partitions missing from Redshift via Glue Catalog comparison
    COPY via IAM role: arn:aws:iam::267052520423:role/prod-redshift-cluster-role
    Cluster: prod-redshift (us-east-2), database: proddb, schema: gold_staging
    Audit trail: gold_staging.redshift_sync_control (status per partition per table)
    Currently syncing: gold_staging.plaid_transactions
    Not yet syncing: plaid_accounts, plaid_items (remain in S3 Silver only)
    ↓
[dbt-floatme staging models]
    stg_plaid_transactions   source: gold_staging.plaid_transactions
        incremental delete+insert, dist: account_id, sort: [processing_date, account_id, date]
        dedup window: last 7 days ROW_NUMBER() on (account_id, user_id, transaction_id, category_id)
    stg_plaid_accounts       source: prod_redshift_silver.plaid_accounts
        incremental delete+insert, unique key: (account_id, user_id, item_id)
    stg_plaid_prod_txn       source: prod_redshift_silver.prod_txn_plaid  (DynamoDB Streams direct)
        tags: plaid, users, plaid_errors, main_bank_account
    ↓
dbt intermediates & marts
    → ML feature engineering (37 features)
    → CatBoost underwriting model (threshold: 0.55)

Key infrastructure files:

Component File

Bronze Glue job (Terraform)

data-engineering/deploy/glue.tf

Bronze script

data-engineering/scripts/bronze/plaid/bronze_plaid.py

Silver scripts (4)

data-engineering/scripts/silver/plaid/silver_plaid_*.py

Bronze Airflow DAG

mwaa-airflow/floatme-airflow/dags/bronze_plaid.py

Silver Airflow DAG

mwaa-airflow/floatme-airflow/dags/silver_plaid.py

Redshift Sync DAG

mwaa-airflow/floatme-airflow/dags/redshift_sync_silver.py

Redshift Sync config

mwaa-airflow/floatme-airflow/dags/redshift_sync_silver.toml

dbt sources

dbt-floatme/models/staging/plaid/_plaid__sources.yml

dbt models

dbt-floatme/models/staging/plaid/stg_plaid_*.sql

Migration gap to note: The Snowflake pathway tracks removed_transactions and replaced_transactions as distinct tables. These are not yet present in the Redshift pathway. Before Snowflake is decommissioned, this coverage needs to be verified or added.


Credentials & Secrets Management

Service Credential Storage Variable Names

transactions-service

AWS Secrets Manager

TXN_PLAID_CLIENT_ID, TXN_PLAID_SECRET, TXN_PLAID_ENV

user-service

AWS Secrets Manager

PLAID_CLIENT_ID, PLAID_SECRET, PLAID_ENV

floatme-api ⚠️ retiring

Environment variables

PLAID_CLIENT_ID, PLAID_SECRET, PLAID_ENV

credit-card-service ⚠️ archiving

Terraform-injected env vars

CC_PLAID_CLIENT_ID, CC_PLAID_SECRET, CC_PLAID_ENVIRONMENT

payments-service

AWS Secrets Manager

SM_PLAID_NAME → credentials

fmsdk

Viper config / env vars

clientID, secret, environment

qa-automation

AWS Secrets Manager

SM_PLAID_NAMEtest/plaid secret

machine-learning CI/CD

GitHub Secrets

TEST_PLAID_CLIENT_ID, TEST_PLAID_SECRET, TEST_PLAID_ENVIRONMENT

infrastructure (Slack alerts)

AWS SSM Parameter Store

/prod/ops/slack_webhook_plaid


SDK Version Inventory

Repo SDK Version Status

transactions-service

plaid-go

v32.1.0

Current

payments-service

plaid-go

v25.0.0

Behind — 7 major versions

user-service

plaid-go

v15.3.0 (via fmsdk)

Behind — 17 major versions

credit-card-service ⚠️ archiving

plaid-go

v15.3.0 (via fmsdk)

To be archived

insight-service ⚠️ uncertain

plaid-go

v15.3.0 (direct)

Behind — confirm if still active

fmsdk

plaid-go

v15.3.0

Behind

floatme-api ⚠️ retiring

plaid-go

v0.0.0-20210525

To be decommissioned — infra still live

qa-automation

plaid-go

v8.2.1

Old — 24 major versions behind

floatme-flutter

plaid_flutter

5.0.5

Check for updates


Key Technical Risks

1. SDK Fragmentation (mostly a dead-code problem)

Nine versions of plaid-go exist in go.mod files, but once credit-card-service is archived and floatme-api’s Plaid code is removed, the meaningful versions reduce to: v32 (transactions-service), v25 (payments-service), v15 (fmsdk + user-service + insight-service), and v8 (qa-automation). The fmsdk pins at v15 — upgrading it would bring user-service, float-service, and other consumers along for free. qa-automation on v8 is the most actionable gap.

2. floatme-api Plaid Infrastructure Needs Decommissioning

The service is being retired, but its Plaid infrastructure is still deployed: a webhook-handler Lambda, primary + secondary SQS queues, and a PostgreSQL table (FloatMeAPI_useraccountsmodel) storing Plaid access tokens. The access token table warrants particular attention — confirm it is encrypted at rest and coordinate token cleanup before decommissioning.

3. No Income Product

Plaid Income is not used anywhere. Income verification is handled manually via insight-service. This is an intentional architectural decision.

4. Processor Token Scope

Only Array is configured as a processor token recipient. If other processors are needed in the future, transactions-service would be the sole place to add them.

5. Chime/Varo Hard-Coded Institution IDs

underwriting-service contains hardcoded Plaid institution IDs (ins_35, ins_115640, ins_129229, ins_132289) for special balance threshold rules. These should be verified as still current — Plaid occasionally changes institution IDs.

6. qa-automation Uses a Very Old SDK

plaid-go v8 is significantly behind. The sandbox API surface has changed and test data setup may not accurately reflect production behavior.

7. Data Pipeline Has Placeholder IAM Role

mwaa-airflow/staging_plaid_transactions.py contains a placeholder YOUR_ACCOUNT_ID in the IAM role ARN for the Redshift COPY command — this should be verified as properly resolved at deploy time.

8. Every Active Item Incurs a Monthly Transactions Charge Regardless of Usage

Transactions is set as a required product in FloatMe’s link token, which means Plaid starts billing the monthly subscription at Item creation — not on first API call. Every Item in prod-txn-plaid with status ACTIVE is generating a monthly charge, including Items belonging to inactive users or Items stuck in an error state. Charges stop only when /item/remove is called. This makes Item lifecycle management a direct cost control lever: Items that are never cleaned up after user churn, deactivation, or error states accumulate indefinitely. The dbt-floatme/int_finance__plaid_costs.sql model tracks this, but it’s worth auditing how aggressively stale Items are removed.

Auth and Liabilities behave differently: Auth (optional_products) is only billed on first /auth/get call; Liabilities (additional_consented_products) is only billed when the first /liabilities/get call occurs (gated by EnableLiabilityMining).

9. Transaction Collection Relies on a Synthetic Webhook (Known Technical Debt)

Transaction fetching for a new Item is not triggered by Plaid’s real INITIAL_UPDATE webhook — it’s triggered by a synthetic webhook that FloatMe manufactures and sends to itself immediately after item creation (postItemSaveActions() in items_v2.go). The code comment acknowledges this explicitly:

"This is just a fail-safe and can be removed once we can update our Plaid integration to set the webhook URL on link token create and don’t have a risk of missing any initial update webhooks."

The root cause is a race condition: the webhook URL isn’t registered with Plaid until after the item is saved, so there’s a window where Plaid’s real INITIAL_UPDATE could fire before the URL is set. The synthetic webhook closes that gap.

The synthetic webhook is not sent unconditionally — shouldSendSyntheticWebhook() gates it on three conditions: new onboarding flow, reactivation_v2 GrowthBook flag, or txn.plaid_initial_update.synthetic_webhook GrowthBook flag. Users who don’t match any of these rely solely on Plaid’s real webhook, which means the race condition is still theoretically present for those users.

The fix described in the comment — setting the webhook URL on /link/token/create rather than after item creation — would eliminate the race entirely and remove the need for the synthetic webhook.

10. Item Removal Coverage for Churned Users Is Unclear

FloatMe has four triggers for calling /item/remove (user disconnect, permission revocation, Item TTL expiry, Layer stub expiry — see Item Lifecycle section). However, it is not clear from the code whether user churn or account deactivation reliably sets the Item TTL field that drives the TTL-based removal. If an Item’s TTL is never set, it will persist in Plaid indefinitely and continue generating monthly Transactions charges. The gap between active Item count in prod-txn-plaid and active user count is the number to audit — dbt-floatme/int_utilities__user_plaid_revoked.sql and int_finance__plaid_costs.sql are the right starting points.