Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5505,6 +5505,18 @@ export function AsanaIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function PlaidIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
fill='currentColor'
fillRule='evenodd'
d='M18.637 0L4.09 3.81.081 18.439l5.014 5.148L0 28.65l3.773 14.693 14.484 4.047 5.096-5.064 5.014 5.147 14.547-3.81 4.008-14.63-5.013-5.146 5.095-5.063L43.231 4.13 28.745.083l-5.094 5.063L18.637 0zM9.71 6.624l7.663-2.008 3.351 3.44-4.887 4.856L9.71 6.624zm16.822 1.478l3.405-3.383 7.63 2.132-6.227 6.187-4.808-4.936zM4.672 17.238l2.111-7.705 6.125 6.288-4.886 4.856-3.35-3.44zm29.547-1.243l6.227-6.189 1.986 7.74-3.404 3.384-4.809-4.935zm-15.502-.127l4.887-4.856 4.807 4.936-4.886 4.856-4.808-4.936zm-7.814 7.765l4.886-4.856 4.81 4.936-4.888 4.856-4.808-4.936zm15.503.127l4.886-4.856L36.1 23.84l-4.887 4.856-4.807-4.936zM4.57 29.927l3.406-3.385 4.807 4.937-6.225 6.186-1.988-7.738zm14.021 1.598l4.887-4.856 4.808 4.936-4.886 4.856-4.809-4.936zm15.502.128l4.887-4.856 3.351 3.439-2.11 7.705-6.128-6.288zm-24.656 8.97l6.226-6.189 4.81 4.936-3.406 3.385-7.63-2.133zm16.843-1.206l4.886-4.856 6.126 6.289-7.662 2.007-3.35-3.44z'
/>
</svg>
)
}

export function PipedriveIcon(props: SVGProps<SVGSVGElement>) {
const pathId = useId()
return (
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ import {
PersonaIcon,
PineconeIcon,
PipedriveIcon,
PlaidIcon,
PolymarketIcon,
PostgresIcon,
PosthogIcon,
Expand Down Expand Up @@ -464,6 +465,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
persona: PersonaIcon,
pinecone: PineconeIcon,
pipedrive: PipedriveIcon,
plaid: PlaidIcon,
polymarket: PolymarketIcon,
postgresql: PostgresIcon,
posthog: PosthogIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
"pinecone",
"pipedrive",
"pipedrive-service-account",
"plaid",
"polymarket",
"postgresql",
"posthog",
Expand Down
191 changes: 191 additions & 0 deletions apps/docs/content/docs/en/integrations/plaid.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
---
title: Plaid
description: Read bank accounts, balances, transactions, and identity data via Plaid
---

import { BlockInfoCard } from "@/components/ui/block-info-card"

<BlockInfoCard
type="plaid"
color="#111111"
/>

{/* MANUAL-CONTENT-START:intro */}
[Plaid](https://plaid.com/) connects applications to financial accounts through a consent-based Item created with Plaid Link. Sim reads an existing Item; it does not run Link or return long-lived Item access tokens from workflow actions.

## Before you connect

1. In the [Plaid Dashboard](https://dashboard.plaid.com/), copy the application **client ID** and the secret for the environment you will use.
2. Create the Item through Plaid Link in your application and exchange its public token on your server. Plaid public tokens expire after 30 minutes. The resulting Item access token is long-lived until it is revoked or rotated and must not be embedded in client-side application code or stored in workflow state. Enter it only in Sim's credential form, which sends it to the authenticated credential API for validation and encryption and does not return it. For Sandbox testing, create and exchange a Sandbox public token through Plaid's server-side Sandbox API.
3. Connect a Plaid Item from **Integrations**, or add a Plaid block and open **Plaid Item**. Enter the environment, client ID, matching secret, and Item access token. Sim verifies the values with Plaid `/item/get`, encrypts them, and gives workflows only the credential's opaque ID. Create one credential per Item.

## Usage notes

- Select the stored Plaid Item once per block. Reconnect the credential after rotating the Plaid access token or environment secret; the opaque credential ID stays the same for existing workflows. Deleting the Sim credential removes only the local encrypted copy and does not revoke or remove the Item at Plaid.
- Transaction Sync returns one page per call. Preserve `nextCursor` and continue while `hasMore` is true. If Plaid returns `TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION`, discard that batch and restart from the cursor where the batch began. A cursor belongs to its account-filter stream; start with no cursor after changing the account filter.
- Account fields offer single- and multi-account selectors backed by the selected Item. Institution lookup offers searchable results and hydrates a saved selection by ID. Advanced manual fields remain available for account or institution IDs that cannot be loaded in the editor. Account filters are optional and default to all accounts on the Item.
- Institution search returns at most ten matches. The Search Institutions action remains available when you need its full institution records or want to supply non-US country codes and product filters.
- Get Balances usually completes in under ten seconds but can take 30 seconds or more. `minLastUpdatedDatetime` is an RFC 3339 date-time and is required by Plaid only for certain Capital One non-depository requests.
- Get Auth returns full account and routing identifiers for downstream payment steps. Sim hides the `numbers` field from execution-log display; do not write it to tables, files, messages, or other durable outputs.
- Plaid Sandbox is useful for contract testing but does not reproduce all Production institution behavior. Product access, optional fields, consent, and institution-specific errors still need Production validation.
{/* MANUAL-CONTENT-END */}


## Usage Instructions

Connect a reusable Plaid Item credential to sync categorized transactions, list linked bank accounts, fetch balances and account numbers, retrieve account-holder identity, inspect Item health, and look up supported institutions.



## Actions

### Plaid Sync Transactions

Incrementally sync transactions for a linked Item. Omit the cursor on the first call to get full history, then pass the returned cursor to fetch only changes; loop while hasMore is true. If Plaid returns TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION, discard the pages from the current batch and restart the loop from the cursor the batch started with

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `cursor` | string | No | Cursor from a previous sync \(nextCursor\); omit to start from the beginning |
| `count` | number | No | Number of updates to fetch per page \(1-500, default 100\) |
| `accountId` | string | No | Scope the sync \(and cursor\) to a single account ID |
| `includeOriginalDescription` | boolean | No | Include the unmodified original_description from the institution |
| `daysRequested` | number | No | Days of history to request \(1-730, default 90\). Only applies before Transactions is initialized on the Item |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `added` | array | Transactions added since the cursor |
| `modified` | array | Transactions modified since the cursor |
| `removed` | array | Transactions removed since the cursor |
| ↳ `transaction_id` | string | ID of the removed transaction |
| ↳ `account_id` | string | Account the transaction belonged to |
| `nextCursor` | string | Cursor to pass to the next sync call to fetch only new changes |
| `hasMore` | boolean | Whether more updates are available; if true, call again with nextCursor |
| `updateStatus` | string | Sync readiness, including TRANSACTIONS_UPDATE_STATUS_UNKNOWN, NOT_READY, INITIAL_UPDATE_COMPLETE, or HISTORICAL_UPDATE_COMPLETE |

### Plaid Get Accounts

List the accounts linked to an Item with their names, types, and balances. Balances may be cached; use Get Balances for real-time values

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `accounts` | array | Accounts linked to the Item |
| `count` | number | Number of accounts returned |

### Plaid Get Balances

Get real-time balances for the accounts linked to an Item. The live institution fetch is usually under 10 seconds but can take 30 seconds or more

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) |
| `minLastUpdatedDatetime` | string | No | Oldest acceptable balance timestamp \(ISO 8601\). Only required for Capital One non-depository accounts |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `accounts` | array | Accounts with refreshed real-time balances |
| `count` | number | Number of accounts returned |

### Plaid Get Identity

Get account-holder identity information (names, emails, phone numbers, and addresses) for the accounts linked to an Item

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `accounts` | array | Accounts with their owners identity data |
| ↳ `owners` | array | Account owners with names, phone numbers, emails, and addresses |
| `count` | number | Number of accounts returned |

### Plaid Get Auth

Get account and routing numbers for depository accounts linked to an Item (ACH for US, EFT for Canada, BACS for UK, IBAN/BIC internationally). Check verification_status before use; null or empty means neither micro-deposit nor database verification applies

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `accounts` | array | Depository accounts on the Item |
| `numbers` | object | Account and routing numbers grouped by scheme |

### Plaid Get Item

Get metadata and health status for a linked Item, including its institution, enabled products, and any error state

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `item` | object | Item metadata |
| `status` | object | Item health: last successful/failed transaction and investment updates and the last webhook fired |

### Plaid Search Institutions

Search financial institutions supported by Plaid by name, returning at most 10

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | Institution name to search for, e.g. 'Chase' |
| `countryCodes` | string | No | Comma-separated ISO country codes to search in \(defaults to 'US'\) |
| `products` | string | No | Comma-separated products the institutions must support, e.g. 'transactions,auth' |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `institutions` | array | Institutions matching the search |
| `count` | number | Number of institutions returned |

### Plaid Get Institution

Get details for a financial institution by its Plaid institution ID

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `institutionId` | string | Yes | Plaid institution ID, e.g. 'ins_109508' |
| `countryCodes` | string | No | Comma-separated ISO country codes \(defaults to 'US'\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `institution` | object | Institution details |


16 changes: 16 additions & 0 deletions apps/sim/app/api/tools/plaid/error-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { extendInternalErrorPolicy, internalErrorResponse } from '@/lib/api/server/routes'
import { internalCredentialDetailErrorPolicy } from '@/lib/credentials/api/route-policies'
import { PlaidGatewayError, PlaidProviderError } from '@/tools/plaid/utils.server'

export const plaidErrorPolicy = extendInternalErrorPolicy(
internalCredentialDetailErrorPolicy,
(error) => {
if (error instanceof PlaidProviderError) {
return internalErrorResponse(error.status, error.body)
}
if (error instanceof PlaidGatewayError) {
return internalErrorResponse(502, { error: error.message })
}
return null
}
)
94 changes: 94 additions & 0 deletions apps/sim/app/api/tools/plaid/options/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() }))

vi.mock('@/lib/credentials/application/list-plaid-options', async () => {
const { credentialOperations } = await vi.importActual<
typeof import('@/lib/credentials/application/operations')
>('@/lib/credentials/application/operations')
return {
listPlaidOptions: {
operation: credentialOperations.read,
execute: mockExecute,
},
}
})

import { OrchestrationError } from '@/lib/core/orchestration/types'
import { POST } from '@/app/api/tools/plaid/options/route'

const body = {
kind: 'accounts',
workspaceId: 'workspace-1',
credentialId: 'credential-1',
} as const

function request(requestBody: unknown = body, headers: Record<string, string> = {}) {
return createMockRequest('POST', requestBody, headers)
}

describe('POST /api/tools/plaid/options', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mockExecute.mockResolvedValue({ options: [{ id: 'acc-1', label: 'Checking' }] })
})

it('accepts a session and forwards only selector scope plus cancellation', async () => {
const incoming = request()
const response = await POST(incoming)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
options: [{ id: 'acc-1', label: 'Checking' }],
})
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { body, signal: incoming.signal },
request: incoming,
})
)
})

it('rejects unauthenticated and API-key callers', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
expect((await POST(request())).status).toBe(401)
expect((await POST(request(body, { 'x-api-key': 'key' }))).status).toBe(401)
expect(mockExecute).not.toHaveBeenCalled()
})

it('rejects malformed and overlong selector requests before execution', async () => {
expect((await POST(request({ ...body, unexpected: true }))).status).toBe(400)
expect(
(
await POST(
request({
...body,
kind: 'institution_search',
query: 'x'.repeat(257),
country_codes: ['US'],
})
)
).status
).toBe(400)
expect(mockExecute).not.toHaveBeenCalled()
})

it.each([
[new OrchestrationError('not_found', 'Credential not found'), 404],
[new OrchestrationError('forbidden', 'Credential access required'), 403],
])('projects credential access failures', async (error, status) => {
mockExecute.mockRejectedValueOnce(error)
const response = await POST(request())
expect(response.status).toBe(status)
expect(JSON.stringify(await response.json())).not.toContain('item-token')
})
})
22 changes: 22 additions & 0 deletions apps/sim/app/api/tools/plaid/options/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { plaidOptionsContract } from '@/lib/api/contracts/selectors/plaid'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listPlaidOptions } from '@/lib/credentials/application/list-plaid-options'
import { credentialOperations } from '@/lib/credentials/application/operations'
import { plaidErrorPolicy } from '@/app/api/tools/plaid/error-policy'

export const dynamic = 'force-dynamic'

export const POST = defineInternalJsonRoute({
contract: plaidOptionsContract,
auth: internalSessionAuth,
operation: credentialOperations.read,
rateLimit: internalRateLimits.none({ reason: 'Bounded editor selector request' }),
errorPolicy: plaidErrorPolicy,
parseOptions: { maxBodyBytes: 64 * 1024 },
mapInput: ({ body }, { request }) => ({ body, signal: request.signal }),
useCase: listPlaidOptions,
})
Loading
Loading