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

EventFires whenWhere in the journey
banklink.completedA bank-link enrollment finishesCardholder connected — Quick Start step 4
transactionmessage.created + transaction.createdA bank-only movement postsFees, transfers, payroll, FX markup
transactionmessage.created + transaction.updatedA linked card's auth matches a bank postingEarlier settlement
bankaccount.state_changedA connection's state changesRe-auth needed, or recovered
banktransaction.createdAny bank transaction is syncedEvery synced bank transaction
transaction.match.createdA bank transaction is matched to a card transactionReconciliation signal

The lifecycle events

banklink.completed

Sent 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

Sent 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_-prefixed transactionReference,
  • a bankData provenance 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-sourced transactionmessage.created payloads and the _embedded.messages[] projection) equals banktransaction.created.id and transaction.match.created.matches[].id, and resolves via GET /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

One 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

Sent 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 filters bankAccountId, 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.created never fires.

Next steps