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 six dedicated events you can subscribe to independently: two lifecycle events (banklink.completed, bankaccount.state_changed) and four bank-record events (banktransaction.created, banktransaction.deleted, transaction.match.created, transaction.match.deleted). Completing a bank link can also create cards, announced on the cardsubscription.created event you may already consume, with enrollmentType: "bank-feed"; see Unified Card & Bank Feeds.

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)
cardsubscription.createdA completed bank link creates a card (enrollmentType: "bank-feed")One per created card; unordered relative to banklink.completed
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
banktransaction.deletedA previously announced bank transaction is retractedDrop the bank record you hold for that id
transaction.match.createdA bank transaction is matched to a card transactionReconciliation signal
transaction.match.deletedA previously announced match is retractedFollows a retracted bank transaction, among other causes

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 fires exactly once per completion, carrying the completed bankLinkId and all the accounts discovered under it in a single delivery, so a push-only consumer reacts to a new connection without polling GET /bank-links, and without coalescing duplicate completion events.

Each account row carries its mask (for credit accounts, the card number's last 4), normalized type, and the cards attached to it at completion. cardIds lists every linked card, including already-enrolled cards matched by last 4; cards[] appears for credit accounts where completion created a card, pairing the new cardId with its subscription. A cardsubscription.created event fires separately for each created card, and delivery order relative to this event is not guaranteed.

{
  "bankLinkId": "enrollment_792dcb7d",
  "bankAccounts": [
    {
      "id": "3a8f2c1d-5e7b-4d9a-b6c8-9f0e1d2a3b4c",
      "name": "Checking",
      "mask": "6789",
      "type": "checking",
      "cardIds": []
    },
    {
      "id": "059d5e6a-cfb1-46e6-ab84-6586582b6b58",
      "name": "Platinum Card",
      "mask": "1005",
      "type": "credit",
      "cardIds": ["9e107d9d-372b-4d1a-9f4a-654321abcdef"],
      "cards": [
        {
          "id": "9e107d9d-372b-4d1a-9f4a-654321abcdef",
          "subscriptionId": "1b671a64-40d5-491e-99b0-da01ff1f3341",
          "last4digits": "1005",
          "network": "amex",
          "enrollmentType": "bank-feed"
        }
      ]
    }
  ]
}

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, plus everything on an account with no linked or created cards) 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.

A bank-only movement always carries network: "OPEN_BANKING". It never adopts a card network, so it can never masquerade as a card clearing. On an account with no linked or created cards it also carries cardId: null (the _links.card link is omitted). On an account that does have linked cards, an unmatched bank movement is attributed to the account's oldest linked card and carries that card's cardId, for feed grouping only; the network stays "OPEN_BANKING". The approvalCode/transactionReference markers above remain the universal classifiers.

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. The provisional bank settlement is superseded rather than removed at that point. Its id joins the payload's supersededMessageIds and its embedded projection flips to superseded: true, keeping the bank provenance visible without double-counting (see Superseded messages).

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. A retraction is also announced push-side: a banktransaction.deleted fires with the same id the original banktransaction.created carried, and any match on it is retracted with transaction.match.deleted.

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. amount is always positive. Direction is carried by transactionType (debit or credit). category is the primary personal-finance category (or null), merchantName may be null, and pending is currently always false.

{
  "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, "diff": 0, "message": "Exact match" },
    { "type": "Merchant", "score": 0.88, "cardValue": "UBER   *TRIP", "bankValue": "Uber", "diff": 12, "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, minAmount, maxAmount, transactionType, matched.

Bank-only mode

You can use bank linking without enrolling any card numbers, as a standalone bank-data source in place of card enrollment. This mode is no longer card-less: completion still creates a card (an active bank-feed subscription, announced by cardsubscription.created) for each credit account that carries a usable last 4, and that account's synced transactions are attributed to the created card's cardId. An account that exposes no usable last 4 gets no created card, and its movements carry cardId: null. Either way, with no card-network enrollment there is nothing to match, and every synced bank transaction surfaces the same way: 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-network transactions to match against, transaction.match.created never fires.

Next steps


Did this page help you?