Bank Linking Webhooks & Events
Introduction
Bank linking adds no new webhook types to integrate. Bank-observed money movement rides the same transactionmessage.created, transaction.created, and transaction.updated events you already handle — same schema, same handlers. On top of that, the banking service emits four dedicated events you can subscribe to independently: two lifecycle events (banklink.completed, bankaccount.state_changed) and two bank-record events (banktransaction.created, transaction.match.created).
This page orients you to what fires when across the bank-linking journey. The canonical payload schemas for every event live in the Webhook Event Types reference — link out to it for field-level detail rather than copying shapes into your integration.
Events at a glance
| Event | Fires when | Where in the journey |
|---|---|---|
banklink.completed | A bank-link enrollment finishes | Cardholder connected — Quick Start step 4 |
transactionmessage.created + transaction.created | A bank-only movement posts | Fees, transfers, payroll, FX markup |
transactionmessage.created + transaction.updated | A linked card's auth matches a bank posting | Earlier settlement |
bankaccount.state_changed | A connection's state changes | Re-auth needed, or recovered |
banktransaction.created | Any bank transaction is synced | Every synced bank transaction |
transaction.match.created | A bank transaction is matched to a card transaction | Reconciliation signal |
The lifecycle events
banklink.completed
banklink.completedSent when an enrollment finishes — via the hosted-link flow (Plaid SESSION_FINISHED plus the polling fallback) or the manual PATCH /bank-links/{bankLinkId} completion. It carries the completed bankLinkId and the accounts discovered under it, so a push-only consumer reacts to a new connection without polling GET /bank-links.
{
"bankLinkId": "enrollment_792dcb7d",
"bankAccounts": [
{ "id": "3a8f2c1d-5e7b-4d9a-b6c8-9f0e1d2a3b4c", "name": "Checking" }
]
}bankaccount.state_changed
bankaccount.state_changedSent whenever a linked account's connection state actually changes — to auth_required (with a reason and, when known, an expiresAt), to disconnected, or back to active on recovery. Use it to prompt the cardholder to re-authenticate — and to stop prompting — without polling GET /bank-accounts.
{
"bankAccountId": "3a8f2c1d-5e7b-4d9a-b6c8-9f0e1d2a3b4c",
"state": "auth_required",
"previousState": "active",
"reason": "consent_expiring",
"expiresAt": "2026-07-09T00:00:00.000Z"
}state is one of active, auth_required, disconnected; reason and expiresAt are null unless applicable. When an account is in auth_required, restore it with POST /bank-accounts/{id}/refresh — see Keep connections healthy.
Bank activity on your existing webhooks
Bank money movement flows on the transaction* events you already operate — there is no new shape to parse. Three behaviors are worth knowing:
1. Bank-only transactions. A movement with no card counterpart (payroll, transfers, fees, FX markup — and, on a card-less bank link, everything) arrives as a transactionmessage.created carrying a synthetic FINL_ADVC, followed by a transaction.created for the new SETTLED aggregate. The universal markers of a bank-only movement are:
- no
approvalCode, - a
bank_-prefixedtransactionReference, - a
bankDataprovenance block.
On a card-less account, a bank-only movement also carries network: "OPEN_BANKING" and cardId: null (the _links.card link is omitted). On an account that does have linked cards, an unmatched bank movement is attributed to a linked card and carries that card's network and cardId — so don't classify bank-only vs card-backed on network alone; use the approvalCode/transactionReference markers above.
2. Earlier settlement. When a bank posting matches one of your card transactions with high confidence, the transaction settles early: a bank-sourced FINL_ADVC (inheriting the auth's transactionReference, approvalCode, and network) and a transaction.updated flipping PENDING→SETTLED — typically hours after the swipe instead of days. When the card network's own clearing arrives later, settled amounts may revise once more (tips, FX); the network remains the financial authority, and amount revision on transaction.updated is expected, not an error.
3. Bank-first, card-later. If the bank movement arrived first as a standalone transaction and card data shows up later, the movement re-settles onto the card transaction and the standalone's transaction.updated revises its settled amounts to 0 (status stays SETTLED). Consumers that already process amount revisions get this for free. Key state by bankData.bankTransactionId and treat each event as an idempotent upsert.
One join key across every surface
bankData.bankTransactionId(on bank-sourcedtransactionmessage.createdpayloads and the_embedded.messages[]projection) equalsbanktransaction.created.idandtransaction.match.created.matches[].id, and resolves viaGET /bank-transactions/{subaccountId}/{id}. Stitch the bank-feed events and the unified transaction into one record with no extra REST call.
Two edge cases ride the same idempotent-upsert handling: a bank transaction can be retracted upstream (Plaid removes it), netting its aggregate to 0 like the re-settle above; and a single movement can occasionally arrive twice under different bankTransactionIds (a Plaid duplicate), so don't assume exactly one event per real-world movement.
Full payloads for these three are in the reference: transactionmessage.created, transaction.created, transaction.updated, and the Bank-sourced activity section.
The bank-record events
Beyond the unified transaction* feed, these two events expose the bank side directly and can be subscribed to on their own. banktransaction.created is the primary event if you consume bank linking as a standalone feed — see Bank-only mode.
banktransaction.created
banktransaction.createdOne event per bank transaction synced from a linked account (Plaid sync) — it fires for every synced bank transaction, whether or not a card is linked. Its id is the join key described above. category is the primary personal-finance category (or null), merchantName may be null, pending is currently always false, and transactionType is debit or credit.
{
"id": "7f2a9c4e-3b1d-4e8f-a5c7-6d0e2f9b8a3c",
"bankAccountId": "3a8f2c1d-5e7b-4d9a-b6c8-9f0e1d2a3b4c",
"bankAccountName": "Checking",
"date": "2025-10-15",
"amount": 42.5,
"currency": "USD",
"description": "UBER TRIP",
"merchantName": "Uber",
"category": "Travel",
"pending": false,
"transactionType": "debit"
}transaction.match.created
transaction.match.createdSent when the matching engine links a bank transaction to one of your card transactions — so it fires only when cards are linked. It carries the matchId, the confidence (HIGH ≥ 0.70, MEDIUM ≥ 0.50, LOW ≥ 0.20) and numeric score, the per-factor reasons, a summary of the matched card transaction, and the matched bank transaction(s) under matches[].
{
"matchId": "b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e",
"confidence": "HIGH",
"score": 0.88,
"cardTransaction": {
"id": "9c8b7a6d-5e4f-3c2b-1a0d-9e8f7c6b5a4d",
"cardId": "5dec2c49-0aa5-4683-a317-427eb5d115f3",
"date": "2025-10-15",
"amount": 42.5,
"currency": "USD",
"descriptor": "UBER *TRIP",
"source": "Card"
},
"reasons": [
{ "type": "Amount", "score": 1, "cardValue": 42.5, "bankValue": 42.5, "message": "Exact match" },
{ "type": "Merchant", "score": 0.88, "cardValue": "UBER *TRIP", "bankValue": "Uber", "message": "88% similarity" }
],
"matches": [
{
"id": "7f2a9c4e-3b1d-4e8f-a5c7-6d0e2f9b8a3c",
"bankAccountId": "3a8f2c1d-5e7b-4d9a-b6c8-9f0e1d2a3b4c",
"bankAccountName": "Checking",
"date": "2025-10-15",
"amount": 42.5,
"currency": "USD",
"source": "Plaid",
"description": "UBER TRIP"
}
]
}Full field detail for both is in the Event Types reference.
Both have read endpoints (scope banking:read) — a push-only consumer can fetch the exact resource a webhook just referenced:
GET /transaction-matches/{subaccountId}/{matchId}— one match;GET /transaction-matches/{subaccountId}— list matches.GET /bank-transactions/{subaccountId}/{id}— one bank transaction;GET /bank-transactions/{subaccountId}— list, with filtersbankAccountId,cardId,startDate,endDate,transactionType,matched.
Bank-only mode
You can link a bank account with no cards at all and use bank linking as a standalone bank-data source, in place of card enrollment. With no linked cards there is nothing to match, so every synced bank transaction surfaces as a bank-only event — a banktransaction.created (the raw bank record), plus a synthetic FINL_ADVC on transactionmessage.created and a SETTLED transaction.created aggregate.
What bank-only mode does not give you:
- Card authorizations. Bank linking never emits a card auth (
AUTH_REQ); bank transactions arrive at the bank's posting time, not at swipe time, so there is no real-time authorization stream without a card. - Match events. With no card to match against,
transaction.match.creatednever fires.
Next steps
- Quick Start: Bank Linking — the end-to-end on-ramp.
- Linking Cards to Bank Accounts — enable earlier settlement.
- Webhook Event Types — canonical payload schemas for every event.