Bank Accounts & Linking
Connect insured bank accounts for direct debits. The system uses a two-layer model:
- Bank Accounts -- High-level account metadata (name, bank name, connection type, status). Used for display and association.
- ACH Accounts -- Encrypted credential layer (routing number, account number, encrypted at rest with AES-256-GCM). Used for actual money movement.
Use the Plaid bank linking widget for instant, verified bank connections or add accounts manually.
Embedded Bank Linking (Recommended) #
Embed Plaid's bank linking widget in your UI. The insured selects their bank, authenticates, and you get verified account details -- no manual entry needed. Supports 10,000+ financial institutions.
Step 1: Create a Link Token #
POST /api/v1/plaid/link-token
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
string | Yes | Your internal user identifier |
curl -X POST /api/v1/plaid/link-token \
-H "X-Client-ID: $CLIENT_ID" \
-H "X-Client-Secret: $CLIENT_SECRET" \
-d '{ "user_id": "your_internal_user_id" }'
{
"ok": true,
"data": {
"link_token": "link-sandbox-12345-abcde..."
}
}
Step 2: Open the Bank Linking Widget in Your Frontend #
// Install: npm install react-plaid-link
import { usePlaidLink } from "react-plaid-link";
const { open } = usePlaidLink({
token: linkToken, // from Step 1
onSuccess: (publicToken, metadata) => {
// Send publicToken to your backend
exchangeToken(publicToken, metadata);
},
});
open();
See the Plaid Link documentation for full frontend integration details and supported frameworks.
Step 3: Exchange the Public Token #
POST /api/v1/plaid/exchange
| Field | Type | Required | Description |
|---|---|---|---|
public_token |
string | Yes | Public token from Plaid Link onSuccess callback |
user_id |
string | Yes | Your internal user identifier |
employer_id |
string | No | Employer ObjectId to associate the connection with |
carrier_id |
string | No | Carrier ObjectId (if linking for a carrier) |
curl -X POST /api/v1/plaid/exchange \
-H "X-Client-ID: $CLIENT_ID" \
-H "X-Client-Secret: $CLIENT_SECRET" \
-d '{
"public_token": "public-sandbox-12345-abcde...",
"user_id": "your_internal_user_id",
"employer_id": "681xyz789abc123456789012"
}'
This creates a permanent Plaid connection and automatically syncs the insured's bank accounts.
Step 4: Create an ACH Account from the Linked Bank #
POST /api/v1/bank-accounts/ach/from-plaid
| Field | Type | Required | Description |
|---|---|---|---|
owner_type |
string | Yes | employer or carrier |
owner_id |
string | Yes | Entity ObjectId |
plaid_account_id |
string | Yes | Plaid account ID from the connection |
plaid_item_id |
string | Yes | Plaid item ID from the connection |
access_token |
string | Yes | Plaid access token (from exchange step) |
nickname |
string | No | Friendly name for the account |
is_primary |
boolean | No | Set as default account |
curl -X POST /api/v1/bank-accounts/ach/from-plaid \
-H "X-Client-ID: $CLIENT_ID" \
-H "X-Client-Secret: $CLIENT_SECRET" \
-d '{
"owner_type": "employer",
"owner_id": "681xyz789abc123456789012",
"plaid_account_id": "acct_123...",
"plaid_item_id": "item_456...",
"access_token": "access-sandbox-abc123...",
"nickname": "Operating Account",
"is_primary": true
}'
The ACH account is pre-verified (no micro-deposits needed) because credentials come directly from the linked bank via Plaid.
Manual Bank Account Entry #
For insureds who prefer not to use the bank linking widget.
Create a Bank Account (Metadata Layer) #
POST /api/v1/bank-accounts
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
string | Yes | User who owns or manages this account |
name |
string | Yes | Account display name |
bank_name |
string | Yes | Bank institution name |
employer_id |
string | No | Employer ObjectId to associate with |
carrier_id |
string | No | Carrier ObjectId to associate with |
connection_type |
string | No | manual or plaid |
primary |
boolean | No | Set as default account |
curl -X POST /api/v1/bank-accounts \
-H "X-Client-ID: $CLIENT_ID" \
-H "X-Client-Secret: $CLIENT_SECRET" \
-d '{
"user_id": "user_abc123",
"name": "Payroll Account",
"bank_name": "Chase",
"employer_id": "681xyz789abc123456789012"
}'
Create an ACH Account (Credential Layer) #
POST /api/v1/bank-accounts/ach
| Field | Type | Required | Description |
|---|---|---|---|
owner_type |
string | Yes | employer or carrier |
owner_id |
string | Yes | Entity ObjectId |
routing_number |
string | Yes | 9-digit routing number |
account_number |
string | Yes | Bank account number (5-17 digits) |
bank_name |
string | Yes | Bank institution name |
account_type |
string | Yes | checking or savings |
nickname |
string | No | Friendly name |
is_primary |
boolean | No | Set as default account |
curl -X POST /api/v1/bank-accounts/ach \
-H "X-Client-ID: $CLIENT_ID" \
-H "X-Client-Secret: $CLIENT_SECRET" \
-d '{
"owner_type": "employer",
"owner_id": "681xyz789abc123456789012",
"routing_number": "021000021",
"account_number": "1234567890",
"bank_name": "Chase",
"account_type": "checking",
"nickname": "Payroll Account"
}'
{
"ok": true,
"data": {
"_id": "684abc123def456789012345",
"account_last4": "7890",
"routing_last4": "0021",
"status": "unverified",
"bank_name": "Chase",
"account_type": "checking"
}
}
Note: Manually-entered ACH accounts start as
unverified. Full routing and account numbers are never returned in API responses -- only the last 4 digits are shown.
Bank Account Management (Metadata Layer) #
List Bank Accounts #
GET /api/v1/bank-accounts?employer_id=...&carrier_id=...&connection_type=plaid&status=active&primary=true&page=1&limit=50
Get a Bank Account #
GET /api/v1/bank-accounts/{id}
Update a Bank Account #
PATCH /api/v1/bank-accounts/{id}
Set as Primary Account #
PATCH /api/v1/bank-accounts/{id}/default
{ "primary": true }
Delete a Bank Account #
DELETE /api/v1/bank-accounts/{id}
ACH Account Management (Credential Layer) #
List ACH Accounts #
GET /api/v1/bank-accounts/ach?owner_id=...&owner_type=employer&status=active&connection_type=plaid
Get an ACH Account #
GET /api/v1/bank-accounts/ach/{id}
Update an ACH Account #
PATCH /api/v1/bank-accounts/ach/{id}
Delete an ACH Account #
DELETE /api/v1/bank-accounts/ach/{id}
Plaid Connections & Transactions #
List Connections #
GET /api/v1/plaid/connections?employer_id=...&carrier_id=...&status=active&page=1&limit=50
Get a Connection #
GET /api/v1/plaid/connections/{id}
Update a Connection (Add Products) #
POST /api/v1/plaid/link-token/update
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
string | Yes | Your internal user identifier |
item_id |
string | Yes | Plaid item ID to update |
Sync Accounts from Connection #
POST /api/v1/plaid/sync-accounts
| Field | Type | Required | Description |
|---|---|---|---|
item_id |
string | Yes | Plaid item ID to sync |
Get Live Balances #
POST /api/v1/plaid/balance
| Field | Type | Required | Description |
|---|---|---|---|
plaid_item_id |
string | Yes | Plaid item ID |
View Bank Transactions #
GET /api/v1/plaid/transactions?employer_id=...&plaid_item_id=...&plaid_account_id=...&date_from=2026-01-01&date_to=2026-03-31&pending=false&page=1&limit=50
Get a Transaction #
GET /api/v1/plaid/transactions/{id}
Transaction Summary #
GET /api/v1/plaid/transactions/summary?employer_id=...&plaid_item_id=...&date_from=2026-01-01&date_to=2026-03-31
{
"ok": true,
"data": {
"total_transactions": 847,
"total_debits_cents": 12500000,
"total_credits_cents": 15200000,
"net_cents": 2700000
}
}
Security #
- Routing and account numbers are encrypted at rest using envelope encryption (AES-256-GCM)
- Credentials are never returned in API responses -- only the last 4 digits are shown
- All bank operations are logged without sensitive data
- Plaid connection tokens are encrypted before storage
- All API requests require tenant-scoped authentication -- you can only access accounts belonging to employers/carriers within your scope