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.
| Action | What it does | Provider call |
|---|---|---|
plaid.getInstitution | Institution name, URL, and optional provider status | cached, or /institutions/get_by_id |
plaid.listAccounts | Accounts under the connection, from the account cache | none |
plaid.getBalances | Fresh available/current/limit balances for selected accounts | /accounts/balance/get |
plaid.listTransactions | Historical, date-bounded read | /transactions/get |
plaid.syncTransactions | Durable 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(sandboxorproduction). The deployment picks the environment; users never choose it. PLAID_REDIRECT_URIregistered 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
- Open Integrations and choose Financial Accounts → Connect institution.
- 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, thenMercury (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. - The connection card shows the institution, environment, and the accounts the bank shared, each as
name ••mask — type/subtype — currency.
Connection states:
| State | Meaning |
|---|---|
| Active | Selectable and runnable |
| Reconnect required | The 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 |
| Connecting | Link 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 |
| Error | A safe, actionable error — read the remedy on the card |
| Disconnected | Kept for audit history; not selectable |
| Abandoned | A 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
| Key | Applies to | Values |
|---|---|---|
operation | all | getInstitution · listAccounts · getBalances · listTransactions · syncTransactions |
connection | all | connection id (UUID) |
accountIds | balances, list, sync | array of account row ids (UUIDs) |
destinationTableId | sync | Data Table id |
includeStatus | getInstitution | true calls the provider for status |
accountType, subtype, currency | listAccounts | all or a value |
dateMode | list, sync | none · absolute · relative:<days> |
startDate, endDate | list, sync | YYYY-MM-DD, inclusive, in the workflow's timezone |
dateField | sync | posted · authorized; affects emitted changes only |
status | list, sync | all · posted · pending |
direction | list, sync | all · inflow · outflow |
amountMin, amountMax | list, sync | decimal strings, absolute amount |
search | list, sync | case-insensitive match on merchant, name, description, check number, reference |
categories | list, sync | array of category primaries, e.g. INCOME |
limit | list | total output limit |
changeTypes | sync | subset 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 wheneverremovedis 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.providerValuekeeps 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.
Loopfour