Loopfour
IntegrationsFinance

Financial Accounts

Connect bank and credit-card accounts through Plaid, read balances and transactions, and run a durable daily transaction sync into a Data Table

Financial Accounts

Connect a company's bank and credit-card accounts, read institution details, accounts, balances, and transactions, and keep a Data Table in step with the bank through a durable daily sync.

Financial Accounts uses Plaid internally. In the product it is always Financial Accounts; Plaid appears here, in connection diagnostics, and in action IDs (plaid.*).

Overview

One connection is one institution authorization (a Plaid Item) — Mercury — Treasury and Brex — Operations are two connections. Each connection exposes one or more accounts (checking, savings, credit card). A workflow step is pinned to exactly one connection and selects one or more of its accounts.

ActionWhat it doesProvider call
plaid.getInstitutionInstitution name, URL, and optional provider statuscached, or /institutions/get_by_id
plaid.listAccountsAccounts under the connection, from the account cachenone
plaid.getBalancesFresh available/current/limit balances for selected accounts/accounts/balance/get
plaid.listTransactionsHistorical, date-bounded read/transactions/get
plaid.syncTransactionsDurable incremental sync into a Data Table/transactions/sync

Every action returns the same normalized envelope (below), so downstream Transform, Condition, Loop, and Agent blocks read one shape regardless of action.

Prerequisites

  • Plaid credentials on the API and the Trigger runtime: PLAID_CLIENT_ID, PLAID_SECRET, PLAID_ENV (sandbox or production). The deployment picks the environment; users never choose it.
  • PLAID_REDIRECT_URI registered in the Plaid dashboard as exactly <studio-origin>/integrations/plaid/oauth. Institutions that authenticate through OAuth (Chase, for example) never return to the app without it — Link simply does not come back.
  • The Plaid Transactions product enabled on the Plaid account.

Connecting an institution

  1. Open Integrations and choose Financial Accounts → Connect institution.
  2. Complete the bank's login in Plaid Link, which opens straight away — nothing is asked first. The connection takes the institution's name once Link completes — Mercury, then Mercury (2) for a second connection to the same bank, counting disconnected ones. Rename it any time from the connection's menu. OAuth institutions redirect to the bank and back.
  3. The connection card shows the institution, environment, and the accounts the bank shared, each as name ••mask — type/subtype — currency.

Connection states:

StateMeaning
ActiveSelectable and runnable
Reconnect requiredThe bank needs you to sign in again — use Reconnect on the Integrations row, or Update connection from Manage when the provider has more than one connection. Common causes are a changed password or MFA, revoked or lapsed bank consent, or the bank migrating its login; it does not mean a wrong password was entered. Steps pinned to this connection will not run until it is repaired. The API reports this state as expired. A refresh that finds no shared accounts is the exception: it reports error with code NO_ACCOUNTS_SHARED, and the card still asks you to reconnect
ConnectingLink or the exchange is still in progress. Closing the bank sign-in window removes the entry straight away. If the window was never closed (the tab was closed, or the browser quit), the entry stays listed showing who started it and when, with Remove on that entry; the confirmation names the attempt, because removing one that a colleague is still completing makes their connection fail. Removed attempts become Abandoned — kept in the audit history, not deleted
ErrorA safe, actionable error — read the remedy on the card
DisconnectedKept for audit history; not selectable
AbandonedA connect attempt that never completed. Kept for audit history; not shown in the accounts dialog or the canvas

In Reconnect required and Error, the card also shows the stored error code in small type (for example Error code: ITEM_LOGIN_REQUIRED). Quote it when contacting support.

Refresh accounts re-reads the account list from the bank; an account the bank stopped sharing is marked unavailable, never deleted. You can also add a bank from inside a block: the connection selector's Connect new plaid opens Link and selects the new connection when it completes (OAuth institutions finish on the Integrations page, then select the connection in the block).

Configuring a block

Drag Financial Accounts from the Finance & Accounting group. Fields appear in this order: Action → Institution connection → Accounts → Data Table (sync only) → filters for the chosen action.

  • Accounts is disabled until an active connection is selected and clears when the connection changes. It defaults to no accounts selected; choose the accounts or use Select all eligible accounts. An account without transaction data cannot be selected for listTransactions / syncTransactions.
  • A saved account that no longer belongs to the connection is a blocking error: the step will not run against a different account.
  • A Financial Accounts step never falls back to another connection. A step with no connection, or with a connection that is inactive or belongs to another company, fails before it runs.

Step config keys

KeyApplies toValues
operationallgetInstitution · listAccounts · getBalances · listTransactions · syncTransactions
connectionallconnection id (UUID)
accountIdsbalances, list, syncarray of account row ids (UUIDs)
destinationTableIdsyncData Table id
includeStatusgetInstitutiontrue calls the provider for status
accountType, subtype, currencylistAccountsall or a value
dateModelist, syncnone · absolute · relative:<days>
startDate, endDatelist, syncYYYY-MM-DD, inclusive, in the workflow's timezone
dateFieldsyncposted · authorized; affects emitted changes only
statuslist, syncall · posted · pending
directionlist, syncall · inflow · outflow
amountMin, amountMaxlist, syncdecimal strings, absolute amount
searchlist, synccase-insensitive match on merchant, name, description, check number, reference
categorieslist, syncarray of category primaries, e.g. INCOME
limitlisttotal output limit
changeTypessyncsubset of added, modified, removed

There is no cursor key. The sync cursor is managed for you.

plaid.listTransactions is always bounded by posted date. Plaid's /transactions/get endpoint applies startDate and endDate to its posted date and provides no authorization-date query axis, so the block does not offer one for historical reads. A saved list step with the legacy dateField: "authorized" fails before connection resolution or the provider request. To repair it in the editor without losing the date range or limit, change a visible filter such as Status and change it back; or use syncTransactions when authorization-date emission filtering is required. This prevents a posted-date provider window plus an authorization-date local filter from silently returning an incomplete intersection as though it were complete.

Example: sync transactions into a Data Table

{
  "id": "sync_mercury",
  "type": "action",
  "name": "Sync Mercury transactions",
  "action": "plaid.syncTransactions",
  "config": {
    "operation": "syncTransactions",
    "connection": "<plaid-connection-row-id>",
    "accountIds": ["<operating-account-row-id>", "<card-account-row-id>"],
    "destinationTableId": "<bank-transactions-table-id>",
    "changeTypes": ["added", "modified", "removed"],
    "dateMode": "none",
    "status": "all",
    "direction": "all"
  }
}

The destination Data Table

Create the table before the first run — the sync validates its columns by name and never adds columns. A data_table.ensureTable step can create it:

{
  "id": "ensure_bank_table",
  "type": "data_table",
  "name": "Ensure Bank Transactions table",
  "config": {
    "operation": "ensureTable",
    "name": "Bank Transactions",
    "folderId": null,
    "columns": [
      { "name": "eventId", "type": "text" },
      { "name": "connectionId", "type": "text" },
      { "name": "accountId", "type": "text" },
      { "name": "providerTransactionId", "type": "text" },
      { "name": "changeType", "type": "text" },
      { "name": "versionHash", "type": "text" },
      { "name": "status", "type": "text" },
      { "name": "postedDate", "type": "date" },
      { "name": "authorizedDate", "type": "date" },
      { "name": "amount", "type": "text" },
      { "name": "absoluteAmount", "type": "text" },
      { "name": "currency", "type": "text" },
      { "name": "direction", "type": "text" },
      { "name": "description", "type": "text" },
      { "name": "merchant", "type": "text" },
      { "name": "reference", "type": "text" },
      { "name": "category", "type": "text" },
      { "name": "pendingTransactionId", "type": "text" },
      { "name": "accountName", "type": "text" },
      { "name": "accountMask", "type": "text" },
      { "name": "reconciliationStatus", "type": "text" },
      { "name": "processedAt", "type": "date" },
      { "name": "lastError", "type": "text" }
    ]
  }
}

Rows are upserted by eventId, so a retry updates the same row instead of inserting a duplicate. New rows arrive with reconciliationStatus: "new"; your workflow owns reconciliationStatus, processedAt, and lastError from there. Amounts are stored as decimal strings.

How the sync works

  • One cursor per account per step. Two workflows reading the same account keep independent cursors and never consume each other's updates.
  • Every page is drained before the cursor moves. Event versions, Data Table rows, and the cursor are committed in one database transaction; if the commit cannot complete, the cursor does not advance and the next run replays the same pages.
  • Filters affect what is emitted, never what is stored. Date, status, direction, amount, category, and text filters decide which committed rows appear in transactions[]; the Data Table always receives everything the bank sent.
  • Removed transactions are tombstones. They carry ids and provenance with changeType: "removed", status: "removed", isTombstone: true, and are emitted whenever removed is enabled — regardless of date filters — so downstream state can be corrected.
  • Pending → posted. The posted transaction carries pendingTransactionId; treat it as replacing the pending one.
  • If the bank changes data mid-pagination, the whole page loop restarts from the original cursor. Nothing partial is exposed.
  • Ceilings are explicit. A very large first sync commits what it drained and fails the run with a message saying the next run resumes; nothing is silently truncated.
  • Concurrency. An overlapping run that loses the cursor race restarts from the cursor the other run persisted.

A run of listTransactions or syncTransactions against a connection in Reconnect required fails with the reconnect message and marks the connection; nothing downstream runs.

Output envelope

Every action returns this shape. Unused collections are empty and unused objects null.

{
  "schemaVersion": "1.0",
  "provider": "plaid",
  "operation": "syncTransactions",
  "fetchedAt": "2026-08-25T08:03:11.442Z",
  "source": { "connectionId": "…", "connectionName": "Mercury — Treasury", "providerItemId": "item_…", "environment": "sandbox" },
  "institution": null,
  "accounts": [],
  "transactions": [
    {
      "id": "plaid:…:txn_789",
      "eventId": "plaid:…:txn_789:sha256:…",
      "providerTransactionId": "txn_789",
      "changeType": "added",
      "status": "posted",
      "isTombstone": false,
      "account": { "id": "…", "providerAccountId": "…", "name": "Operating", "mask": "4821", "type": "depository", "subtype": "checking", "currency": "USD" },
      "dates": { "postedDate": "2026-08-24", "postedAt": null, "authorizedDate": "2026-08-23", "authorizedAt": null },
      "amount": { "value": "1250.00", "absolute": "1250.00", "currency": "USD", "direction": "inflow", "providerValue": "-1250.00", "convention": "cash-perspective" },
      "description": { "name": "ACH CREDIT ACME INV-1042", "original": null, "merchant": "Acme Productions", "reference": "INV-1042", "checkNumber": null },
      "paymentChannel": "other",
      "category": { "primary": "INCOME", "detailed": "INCOME_OTHER_INCOME", "confidence": "HIGH" },
      "counterparties": [],
      "location": null,
      "pendingTransactionId": null,
      "raw": null
    }
  ],
  "pagination": null,
  "sync": { "initialSync": false, "startedFromCursor": true, "nextCursor": "…", "hasMore": false, "pages": 2, "counts": { "added": 1, "modified": 0, "removed": 0, "emitted": 1, "filteredOut": 0 }, "lastSuccessfulSyncAt": "2026-08-25T08:03:11.442Z" },
  "warnings": []
}

Money and direction

  • Money is always a decimal string ("1250.00"), never a number.
  • Amounts are cash perspective: money entering the account is positive (inflow), money leaving is negative (outflow) — for credit cards too, so a card charge is an outflow and a card payment an inflow. providerValue keeps Plaid's original sign for audit.
  • Calendar dates are inclusive and interpreted in the workflow's timezone (the schedule trigger's timezone, else UTC). Date-only values stay date-only.

Several banks in one workflow

Use one Financial Accounts step per connection, then merge with a Transform:

return [...input.sync_mercury.transactions, ...input.sync_brex.transactions];

Every transaction carries source.* and account metadata, so the merged list keeps its provenance. Point a Loop at the merged rows and use eventId as its idempotency key.

Sandbox to production

Sandbox and production are separate deployments (PLAID_ENV); a Sandbox connection cannot be used against production and the runtime refuses the mismatch. Plaid's Sandbox institutions complete inside Link without leaving the page; to exercise the OAuth return, use a Sandbox OAuth institution with PLAID_REDIRECT_URI registered.

On this page