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 |
Plaid |
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 — |
N/A |
FloatMe awareness |
Transparent — handled via |
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 |
Link
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:
-
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.
-
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 |
|
Endpoints |
|
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:
-
New user signup (onboarding) —
onboarding_v3_base_screen.dartnavigates toConnectAccountsPage, which rendersConnectBankWidget. This is the primary path — every new user links their bank as part of signup. If theFeatureFlags.plaidLayerGrowthBook flag is enabled, the Layer MFA flow is attempted first on themfa_signup_page.dartbefore standard Link. -
Reactivation —
reactivation_screen_v2.dartnavigates toConnectAccountsPagewhen a user’s account needs reactivation (e.g. after a failed collection or expired session). SameConnectBankWidgetflow as onboarding. -
Change bank (profile) — from the profile screen,
linked_account_vm.dartnavigates toChangePlaidScreenFromLinkedAccountScreen. The user can swap their linked institution. This uses update mode — the existingaccess_tokenis passed to Plaid so the user doesn’t have to start from scratch. -
Add additional bank account —
connected_accounts_screen_v2.dartandmanage_plaid_connections_screen.dartboth callAddPlaidItemUseCase.execute(), allowing users to connect a second bank account from the Connected Accounts management section. -
Debit card screen —
debit_cards_screen.dartnavigates toConnectAccountsPagein a context where the user is managing payment methods. -
LOC (Line of Credit) onboarding —
loc_onboarding_viewmodel.dartopens 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 |
|
Endpoints |
|
Flow |
Webhook → SQS |
Notes |
Legacy webhook handler in |
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 |
|
Endpoints |
|
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 |
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 |
|
Endpoints |
|
Notes |
High-sensitivity cost item — every call is billed. Capital One requires 3-day lookback (hardcoded in |
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 call — getAndCacheLiveBalances() 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:
-
Float collection retry (
float-service/pkg/collections/retry.go) — when a float repayment fails and is being retried,ProcessRetry()reads the cached account balance viaGetPlaidAccountsWithResponse()withWithLive(false). It applies a $10 buffer: ifavailable_balance > float_amount + $10, the retry proceeds; otherwise it recordsOutcomeLowBalance. 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. -
Subscription collection (income event trigger) (
subscription-service/pkg/collections/webhook-worker.go) — when a Plaidincome_txnevent above a threshold arrives via EventBridge, the webhook worker callsdoBalanceCheck()to confirm the user has sufficient funds before attempting a subscription payment. UsesWithLive(false)— reads cached balance. -
Subscription collection (new account event trigger) — when a user adds or updates their bank account,
transactions-servicefires anew_accountEventBridge event, which triggers thewebhook-balance-workerLambda insubscription-service. This also reads cached balance to evaluate whether to attempt a subscription payment. -
Underwriting rule evaluation (
underwriting-api/pkg/rulerunner/runner.go) —GetAccountBalances()reads up to 32 days of historical balance snapshots from DynamoDB.RuleBalanceRequirementenforces configurablemin_availableandmin_currentthresholds, andRuleSuspiciousHighBalancedenies new accounts with no prior floats but an unusually high balance. -
Subscription ML decider (
subscription-service/pkg/mldecider/MLDecider.go) — reads 7 days of daily balance snapshots (Balance1dthroughBalance7d) plus aBalanceSlopetrend 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 |
|
Endpoints |
|
Types tracked |
Credit cards, student loans, mortgages |
Notes |
Stored but not yet used in underwriting rules. |
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 passesIncludeLiabilities: aws.Bool(false)when listing Items for rule evaluation. Liability data is actively excluded from underwriting. -
admin-api/backoffice—GetLiabilitiesByItemIDis 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 |
|
Endpoints |
|
Processor |
Array (bank-as-a-service) |
Storage |
DynamoDB |
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 |
|
Endpoints |
|
Key files |
|
Notes |
|
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:
-
success—handleKycSuccess()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.) -
failed—handleKycFailed()fires.loc-serviceis called to create aREJECTEDloan application. An Iterable marketing event is fired. User sees a failure screen. They may retry up to 2 times; after 2 failures,LocKycFailureLimitScreenis 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 |
|
Endpoints |
|
Key files |
|
Notes |
Screening ID is returned as part of the IDV session creation response. DynamoDB record is shared with IDV — separate |
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:
-
cleared—handleScreeningCleared()fires. User passes AML check. (Currently logs — no downstream action wired yet.) -
pending_review—handleScreeningPendingReview()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. -
rejected—handleScreeningRejected()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 |
|
Endpoints |
|
Key files |
|
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 |
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_READYevent 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_AVAILABLEfires. -
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 |
|
Endpoints |
|
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 |
|
Endpoints |
|
Notes |
|
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 |
|
Link |
|
Institutions |
|
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 |
Every active Item is on the meter from the moment the user completes Link |
Liabilities |
First |
An Item can exist indefinitely without Liabilities billing if the |
One-time products — charged once per Item, on first use:
| Product | Billing trigger |
|---|---|
Auth |
First |
Per-request products — charged per successful API call:
| Product | Billing trigger |
|---|---|
Balances |
Every |
Transactions Refresh |
Every |
Processor Tokens |
Negotiated — per |
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:
-
Is
EnableLiabilityMiningcurrently enabled in production? -
How many Items in
prod-txn-plaidhaveliabilitiesin theirbilled_products[]array? -
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 → |
Item created, Transactions subscription starts |
|
Item destroyed, Transactions subscription stops |
Calling or not calling |
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 bothplaid/(a direct Plaid API wrapper) andtxns/(an HTTP client for transactions-service). Services that importfmsdk/clientsget 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 — |
Own Plaid client in |
floatme-api ⚠️ retiring |
plaid-go v0 (2021) (direct) |
Yes — |
Own Plaid client; legacy webhook handler, account linking — service being retired; Plaid infra still deployed |
payments-service |
plaid-go v25.0.0 (direct) |
Yes — |
Own hand-rolled Plaid client in |
credit-card-service ⚠️ archiving |
plaid-go v15.3.0 (direct) |
Yes — |
Uses fmsdk plaid wrapper; imports Plaid types directly in tests — to be archived |
insight-service ⚠️ uncertain |
plaid-go v15.3.0 (direct) |
Yes — |
Imports Plaid types directly to wrap |
qa-automation |
plaid-go v8.2.1 (direct) |
Yes — |
Own Plaid client; sandbox API calls for test data setup |
fmsdk |
plaid-go v15.3.0 (direct) |
Yes — |
The shared Plaid wrapper. |
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 |
Calls transactions-service HTTP API; no Plaid API calls |
admin-api |
plaid-go v15.3.0 (transitive via fmsdk/clients) |
No — uses |
Calls transactions-service HTTP API; no Plaid API calls |
float-service |
plaid-go v15.3.0 (transitive via fmsdk/clients) |
No — uses |
Calls transactions-service HTTP API; no Plaid API calls |
underwriting-api |
plaid-go v15.3.0 (transitive via fmsdk/clients) |
No — uses |
Calls transactions-service HTTP API; no Plaid API calls |
entitlements-service |
plaid-go v15.3.0 (transitive via fmsdk/clients) |
No — uses |
Calls transactions-service HTTP API; no Plaid API calls |
subscription-service |
plaid-go v15.3.0 (transitive via fmsdk/clients) |
No — uses |
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 |
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 ( |
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. |
payments-service |
plaid-go v25.0.0 |
Yes — 2 endpoints |
Transactions, Auth |
No |
Hand-rolled |
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 |
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 ( |
infrastructure |
N/A |
N/A |
— |
Webhook gateway |
API Gateway domains: |
edge |
None |
No |
— |
No |
No Plaid integration |
fm-website |
None |
No |
— |
No |
No Plaid integration |
Webhook Infrastructure
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(viacmd/webhookLambda) -
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-Verificationheader -
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 |
|---|---|---|
|
By miner Lambda on every webhook, to refresh |
Free |
|
On every item creation ( |
Free |
|
See triggers below |
Free — stops the monthly Transactions subscription |
/item/remove Triggers
FloatMe calls /item/remove in four situations:
-
User-initiated disconnect — app calls
RemoveItemV2(current) orRemoveItem(deprecated). Checks for active float before allowing removal. -
USER_PERMISSION_REVOKEDwebhook — user revokes access from their bank’s own settings. Miner Lambda (pkg/miner/item.go) handles this automatically. -
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/removewith reason"item expired, user did not complete reactivation". -
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 |
|---|---|
|
Update mode re-auth ( |
|
Fresh Link (account change required) |
|
"Loading your accounts" — no action button |
DynamoDB fields tracked on items:
-
item_status—ACTIVE/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
What FloatMe does NOT handle: consent expiration
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_REQUIREDerror 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 |
|---|---|---|
|
✅ Handled |
|
|
✅ Handled |
|
|
✅ Handled |
Token revoked, item removed from DynamoDB |
Error widget + Reconnect button in app |
✅ Handled |
Update mode for |
|
❌ Not handled |
Silently ignored; no notification, no field set |
|
❌ Not stored |
Field not in DynamoDB item model |
Scheduled item health check |
❌ Not implemented |
No Lambda iterates items or calls |
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 |
|---|---|
|
Items: |
|
Full transaction data synced from Plaid |
|
Institution metadata cache |
Additional tables: accounts, liabilities, identity verification records
PostgreSQL (floatme-api — legacy)
-
FloatMeAPI_useraccountsmodel: storesaccount_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) |
|
Bronze script |
|
Silver scripts (4) |
|
Bronze Airflow DAG |
|
Silver Airflow DAG |
|
Redshift Sync DAG |
|
Redshift Sync config |
|
dbt sources |
|
dbt models |
|
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 |
|
user-service |
AWS Secrets Manager |
|
floatme-api ⚠️ retiring |
Environment variables |
|
credit-card-service ⚠️ archiving |
Terraform-injected env vars |
|
payments-service |
AWS Secrets Manager |
|
fmsdk |
Viper config / env vars |
|
qa-automation |
AWS Secrets Manager |
|
machine-learning CI/CD |
GitHub Secrets |
|
infrastructure (Slack alerts) |
AWS SSM Parameter Store |
|
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.