Skip to content
Back to skills

Plaid Api

ASecurity

Implements Plaid API integration (Auth, Transactions, Identity, Investments,

  • 4 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added June 12, 2026
securitypythongogitapifrontendbackendsecurity

Works with

  • cursor
  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned June 12, 2026

npx -y skills add paulpas/agent-skill-router --skill plaid-api --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Plaid Api?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Plaid Api
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/paulpas-plaid-api/badge)](https://www.skillsdirectory.com/skills/paulpas-plaid-api)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---




name: plaid-api
description: Implements Plaid API integration (Auth, Transactions, Identity, Investments,
  Income) using plaid-python SDK with Link token flow, webhook verification, access
  token storage security, and financial data synchronization patterns.
license: MIT
compatibility: opencode
metadata:
  version: "1.0.0"
  domain: coding
  triggers: plaid, plaid link, plaid auth, plaid transactions, plaid identity, plaid investments, plaid income verification, how do i integrate plaid investments
  archetypes:
  - tactical
  - generation
  anti_triggers:
  - brainstorming
  - vague ideation
  - code golf
  - over-engineering
  response_profile:
    verbosity: low
    directive_strength: high
    abstraction_level: operational
  role: implementation
  scope: implementation
  output-format: code
  content-types:
  - code
  - guidance
  - do-dont
  - examples
  related-skills: coding-stripe-api, coding-shopify-api, coding-paypal-api




---




# Plaid API Integration

Implements production-grade Plaid API integration for bank account linking, transaction data retrieval, identity verification, investment holdings, and income confirmation. When loaded, this skill makes the model implement the `plaid-python` SDK including: Link token creation and frontend Link flow, access token exchange and secure storage, Auth API for account/routing numbers, Transactions API for syncing bank transactions, Identity API for owner verification, Investments API for portfolio data, Income API for employment/income verification, and webhook verification using HMAC-SHA256.

## TL;DR Checklist

- [ ] Use `plaid-python` SDK (not raw HTTP requests)
- [ ] Create `link_token` first; exchange `public_token` → `access_token`
- [ ] Store `access_token` securely (encrypted at rest, NEVER log)
- [ ] Use `client_id`, `secret`, `environment` from environment variables
- [ ] Verify webhooks via `plaid_client.validate_webhook(verification_key, body, headers)`
- [ ] Cursor-based pagination for Transactions sync (`cursor`, `has_more`)
- [ ] Link flow: server creates token → frontend Link → server exchanges public_token
- [ ] Environment: `Sandbox` → `Development` → `Production`

---

## When to Use

Use this skill when:

- Building bank account linking for ACH transfers (Plaid Auth)
- Syncing bank transactions for personal finance apps (Transactions)
- Verifying user identity and account ownership (Identity)
- Accessing investment portfolio data (Investments)
- Confirming employment and income (Income, Income Verification)
- Implementing account balance checks (Balance)
- Building asset verification for lending (Assets)
- Processing webhooks for transaction updates and item status changes
- Verifying account ownership before micro-deposits

---

## When NOT to Use

- For pure payment processing without bank linking — use `coding-stripe-api`
- For PayPal/credit card checkout — use `coding-paypal-api`
- For crypto/blockchain integrations — not Plaid's domain
- When you don't need to access actual bank account data

---

## Core Workflow

1. **Initialize Plaid Client** — Configure `plaid.ApiClient` and `plaid.PlaidApi` using `PLAID_CLIENT_ID`, `PLAID_SECRET`, `PLAID_ENVIRONMENT`. **Checkpoint:** Call `/link/token/create` to verify connectivity.

2. **Create Link Token** — Call `link_token_create()` with: `user.client_user_id`, `products: ["auth", "transactions"]`, `country_codes: ["US"]`, `language: "en"`. **Checkpoint:** Returns `link_token` for frontend Link component.

3. **Frontend Link Flow** — User opens Link, selects bank, logs in, MFA if needed. Link returns `public_token` to frontend, which sends to your backend. **Checkpoint:** public_token expires in 30 minutes — exchange immediately.

4. **Exchange Public Token** — Call `item_public_token_exchange(public_token)` to get `access_token` and `item_id`. **Checkpoint:** `access_token` is PERMANENT (unless revoked), `item_id` is unique bank connection reference.

5. **Store Access Token Securely** — Encrypt `access_token` at rest. Associate with your internal `user_id` and Plaid `item_id`. **Checkpoint:** NEVER log, print, or expose `access_token` to frontend.

6. **Access Product APIs** — Use `access_token` to call: Auth (`auth_get`), Transactions (`transactions_sync`), Identity (`identity_get`), Balance (`accounts_balance_get`), Investments (`investments_holdings_get`). **Checkpoint:** Use cursor-based sync for Transactions (not `transactions_get` v1).

7. **Verify Webhooks** — Use `validate_webhook` to check HMAC signature on incoming webhooks. Handle `DEFAULT_UPDATE` (transactions ready), `NEW_ACCOUNTS_AVAILABLE`, `AUTHENTICATION` (relink needed), `TRANSACTIONS_REMOVED`. **Checkpoint:** Return 200 OK to webhooks after processing.

---

## Implementation Patterns

### Pattern 1: Plaid Client Initialization (BAD vs GOOD)

```python
"""Plaid Python SDK client initialization patterns.

SDK: plaid-python (pip install plaid-python)

Environment variables:
- PLAID_CLIENT_ID: from Plaid Dashboard
- PLAID_SECRET: from Plaid Dashboard (different per environment!)
- PLAID_ENVIRONMENT: sandbox | development | production

CRITICAL: Sandbox, Development, and Production have DIFFERENT secrets.
Using wrong secret = API error.
"""

from __future__ import annotations

import os
import logging
from typing import Any, Optional

logger = logging.getLogger(__name__)


# ===================================================================
# ❌ BAD — hardcoded secrets, no env management
# ===================================================================

def bad_plaid_init_bad() -> Any:
    """❌ BAD: Hardcoded client_id and secret, no configuration."""
    import plaid
    from plaid.api import plaid_api
    
    # ❌ HARDCODED SECRETS! Never commit these!
    configuration = plaid.Configuration(
        host=plaid.Environment.Sandbox,
        api_key={
            "clientId": "5a...real_client_id_here",
            "secret": "11...real_secret_here",
        }
    )
    
    api_client = plaid.ApiClient(configuration)
    client = plaid_api.PlaidApi(api_client)
    
    return client


# ===================================================================
# ✅ GOOD — environment-based, multi-environment support
# ===================================================================

try:
    import plaid
    from plaid.api import plaid_api
    PLAID_SDK_AVAILABLE = True
except ImportError:
    PLAID_SDK_AVAILABLE = False


def get_plaid_environment() -> plaid.Environment:
    """Get Plaid environment from config.

    Maps env name to plaid.Environment enum:
    - sandbox → plaid.Environment.Sandbox
    - development → plaid.Environment.Development
    - production → plaid.Environment.Production
    """
    env_name = os.environ.get("PLAID_ENVIRONMENT", "sandbox").lower()
    
    if env_name == "production":
        return plaid.Environment.Production
    elif env_name == "development":
        return plaid.Environment.Development
    else:
        return plaid.Environment.Sandbox


def get_plaid_client() -> plaid_api.PlaidApi:
    """Get configured PlaidApi client.

    Reads from environment:
        PLAID_CLIENT_ID
        PLAID_SECRET
        PLAID_ENVIRONMENT

    Returns:
        Configured PlaidApi instance.

    Raises:
        RuntimeError: If plaid-python not installed.
        ValueError: If credentials missing.
    """
    if not PLAID_SDK_AVAILABLE:
        raise RuntimeError("plaid-python not installed. pip install plaid-python")
    
    client_id = os.environ.get("PLAID_CLIENT_ID")
    secret = os.environ.get("PLAID_SECRET")
    
    if not client_id or not secret:
        raise ValueError("PLAID_CLIENT_ID and PLAID_SECRET required in environment")
    
    environment = get_plaid_environment()
    
    configuration = plaid.Configuration(
        host=environment,
        api_key={
            "clientId": client_id,
            "secret": secret,
        }
    )
    
    api_client = plaid.ApiClient(configuration)
    client = plaid_api.PlaidApi(api_client)
    
    return client


def format_plaid_error(e: plaid.ApiException) -> dict[str, Any]:
    """Parse Plaid ApiException into structured error dict.

    Plaid returns JSON error bodies in exceptions.
    Error types: INVALID_REQUEST, INVALID_INPUT, INSTITUTION_ERROR,
                  RATE_LIMIT_EXCEEDED, API_ERROR, ITEM_ERROR,
                  INVALID_LINK_TOKEN, etc.

    See: https://plaid.com/docs/errors/
    """
    import json
    
    try:
        error_body = json.loads(e.body)
        return {
            "error_type": error_body.get("error_type"),
            "error_code": error_body.get("error_code"),
            "error_message": error_body.get("error_message"),
            "display_message": error_body.get("display_message"),
            "request_id": error_body.get("request_id"),
            "status": e.status,
        }
    except Exception:
        return {
            "error_type": "UNKNOWN",
            "error_message": str(e),
            "status": getattr(e, "status", None),
        }
```

### Pattern 2: Link Token Flow (Modern Link)

```python
"""Plaid Link token creation and public token exchange.

This is the MODERN Link flow (recommended since 2020):

1. Server: link_token_create() → returns link_token
2. Frontend: Plaid Link component initialized with link_token
3. User: Links bank account in Link UI
4. Frontend: Receives public_token from Link onSuccess
5. Server: item_public_token_exchange(public_token) → access_token + item_id
6. Server: Stores access_token securely (encrypted!)

OLD flow (deprecated): public_key in frontend → use link_token_create instead.
"""

from __future__ import annotations

from typing import Any, Optional
import plaid


def create_link_token(
    client_user_id: str,  # Your internal user ID
    products: list[str],  # ["auth", "transactions", "identity", "investments", "liabilities"]
    country_codes: list[str] | None = None,
    language: str = "en",
    webhook: str | None = None,
    access_token: str | None = None,  # For update mode (relink expired Item)
    client_name: str | None = None,
    redirect_uri: str | None = None,
) -> dict[str, Any]:
    """Create a Link token for initializing Plaid Link.

    Link tokens are short-lived (expire in 4 hours) and are used
    to initialize the Plaid Link component on the frontend.

    Args:
        client_user_id: Your internal persistent user ID.
        products: List of products to initialize (auth, transactions, etc.).
            First product is "primary product" with special behavior.
        country_codes: List of ISO 3166-1 alpha-2 codes (US, CA, GB, etc.).
            Default: ["US"]
        language: Language code (en, es, fr, etc.).
        webhook: URL for Plaid webhook notifications for this Item.
        access_token: If provided, Link runs in "update mode" to relink.
        client_name: Name of your app shown in Link.
        redirect_uri: For OAuth institutions (required for some banks).

    Returns:
        Dict with link_token, expiration, request_id.

    Common products:
        auth: Account and routing numbers for ACH
        transactions: Transaction history
        identity: Account owner info (name, email, phone, address)
        investments: Investment holdings and transactions
        liabilities: Credit cards, loans, mortgages
        income: Income and employment data
        assets: Asset verification for lending
    """
    client = get_plaid_client()
    
    actual_countries = country_codes or ["US"]
    
    user = {
        "client_user_id": client_user_id,
    }
    
    body: dict[str, Any] = {
        "user": user,
        "client_name": client_name or "My App",
        "products": products,
        "country_codes": actual_countries,
        "language": language,
    }
    
    if webhook:
        body["webhook"] = webhook
    if redirect_uri:
        body["redirect_uri"] = redirect_uri
    if access_token:
        body["access_token"] = access_token  # Update mode
    
    try:
        response = client.link_token_create(body)
        return {
            "link_token": response["link_token"],
            "expiration": response.get("expiration"),
            "request_id": response.get("request_id"),
        }
    except plaid.ApiException as e:
        error = format_plaid_error(e)
        logger.error("Plaid link_token_create failed: %s", error)
        raise ValueError(f"Failed to create link token: {error.get('error_message')}") from e


def exchange_public_token(public_token: str) -> dict[str, Any]:
    """Exchange a public_token (from Link success) for access_token and item_id.

    This is the CRITICAL exchange:
    - public_token = short-lived (30 min), from frontend Link onSuccess
    - access_token = PERMANENT, stored securely on your server
    - item_id = unique ID for this bank connection

    Args:
        public_token: The public_token from Link's onSuccess callback.

    Returns:
        Dict with access_token, item_id, request_id.
    """
    client = get_plaid_client()
    
    body = {
        "public_token": public_token,
    }
    
    try:
        response = client.item_public_token_exchange(body)
        return {
            "access_token": response["access_token"],
            "item_id": response["item_id"],
            "request_id": response.get("request_id"),
        }
    except plaid.ApiException as e:
        error = format_plaid_error(e)
        logger.error("Plaid public_token exchange failed: %s", error)
        raise ValueError(f"Failed to exchange public token: {error.get('error_message')}") from e


def invalidate_access_token(access_token: str) -> bool:
    """Revoke/invalidate an access_token (user unlinks bank).

    Call this when a user removes/disconnects their bank account.
    Plaid stops sending webhooks and the access_token becomes invalid.

    Returns:
        True if successful.
    """
    client = get_plaid_client()
    
    body = {
        "access_token": access_token,
    }
    
    try:
        client.item_remove(body)
        logger.info("Plaid item removed (access_token revoked)")
        return True
    except plaid.ApiException as e:
        error = format_plaid_error(e)
        logger.warning("Plaid item_remove failed: %s", error)
        raise ValueError(f"Failed to remove item: {error.get('error_message')}") from e
```

### Pattern 3: Auth, Transactions, Identity APIs

```python
"""Core Plaid product APIs: Auth, Transactions, Identity.

These use the permanent access_token obtained from public_token exchange.
"""

from __future__ import annotations

from typing import Any, Optional
import plaid


def auth_get(access_token: str) -> dict[str, Any]:
    """Get account and routing numbers for ACH transfers (Auth product).

    Returns:
        Dict with accounts, numbers, routing info.
    """
    client = get_plaid_client()
    
    body = {
        "access_token": access_token,
    }
    
    try:
        response = client.auth_get(body)
        return {
            "accounts": response.get("accounts", []),
            "numbers": {
                "ach": response.get("numbers", {}).get("ach", []),
                "eft": response.get("numbers", {}).get("eft", []),  # Canada
                "international": response.get("numbers", {}).get("international", []),
                "bacs": response.get("numbers", {}).get("bacs", []),  # UK
            },
            "item": response.get("item"),
            "request_id": response.get("request_id"),
        }
    except plaid.ApiException as e:
        error = format_plaid_error(e)
        raise ValueError(f"Auth get failed: {error.get('error_message')}") from e


def transactions_sync(
    access_token: str,
    cursor: str | None = None,  # None = first sync; pass cursor for incremental
    count: int = 100,  # Max 500
) -> dict[str, Any]:
    """Sync transactions using cursor-based pagination (v2, RECOMMENDED).

    This is the MODERN way to get transactions (introduced 2022).
    Replaces old transactions_get which had date-range problems.

    Flow:
    1. First call: cursor=None → returns added since Item creation
    2. Store response.next_cursor
    3. Next call: cursor=stored_next_cursor → returns new/modified/removed
    4. Repeat while has_more=True

    Args:
        access_token: The permanent access token.
        cursor: Cursor from previous sync (None for first sync).
        count: Number per page (max 500).

    Returns:
        Dict with added, modified, removed transactions, cursor, has_more.
    """
    client = get_plaid_client()
    
    body: dict[str, Any] = {
        "access_token": access_token,
        "count": min(count, 500),
    }
    
    if cursor:
        body["cursor"] = cursor
    
    try:
        response = client.transactions_sync(body)
        return {
            "added": response.get("added", []),
            "modified": response.get("modified", []),
            "removed": response.get("removed", []),
            "next_cursor": response.get("next_cursor"),
            "has_more": response.get("has_more", False),
            "request_id": response.get("request_id"),
        }
    except plaid.ApiException as e:
        error = format_plaid_error(e)
        raise ValueError(f"Transactions sync failed: {error.get('error_message')}") from e


def sync_all_transactions(
    access_token: str,
    initial_cursor: str | None = None,
) -> dict[str, Any]:
    """Sync ALL transactions by paginating through has_more.

    Handles cursor pagination internally.

    Returns:
        Combined dict of all added, modified, removed, final cursor.
    """
    all_added: list[Any] = []
    all_modified: list[Any] = []
    all_removed: list[Any] = []
    
    cursor = initial_cursor
    
    while True:
        result = transactions_sync(access_token, cursor=cursor, count=500)
        
        all_added.extend(result["added"])
        all_modified.extend(result["modified"])
        all_removed.extend(result["removed"])
        
        cursor = result["next_cursor"]
        
        if not result["has_more"]:
            break
    
    return {
        "added": all_added,
        "modified": all_modified,
        "removed": all_removed,
        "final_cursor": cursor,
    }


def identity_get(access_token: str) -> dict[str, Any]:
    """Get account owner identity information (Identity product).

    Returns owner data from bank: names, emails, phone numbers, addresses.
    Useful for:
    - Verifying user owns the bank account
    - Pre-filling user data in your app
    - KYC/AML compliance checks

    Returns:
        Dict with identity data.
    """
    client = get_plaid_client()
    
    body = {
        "access_token": access_token,
    }
    
    try:
        response = client.identity_get(body)
        return {
            "accounts": response.get("accounts", []),
            "item": response.get("item"),
            "request_id": response.get("request_id"),
        }
    except plaid.ApiException as e:
        error = format_plaid_error(e)
        raise ValueError(f"Identity get failed: {error.get('error_message')}") from e


def accounts_balance_get(access_token: str) -> dict[str, Any]:
    """Get real-time account balances.

    Note: Not all institutions support real-time balance updates.
    Some only update balances once per day.

    Returns:
        Dict with accounts containing current/available balance.
    """
    client = get_plaid_client()
    
    body = {
        "access_token": access_token,
    }
    
    try:
        response = client.accounts_balance_get(body)
        return {
            "accounts": response.get("accounts", []),
            "item": response.get("item"),
            "request_id": response.get("request_id"),
        }
    except plaid.ApiException as e:
        error = format_plaid_error(e)
        raise ValueError(f"Balance get failed: {error.get('error_message')}") from e
```

### Pattern 4: Webhook Verification

```python
"""Plaid webhook verification.

Plaid sends webhooks for:
- Transactions: DEFAULT_UPDATE, HISTORICAL_UPDATE, TRANSACTIONS_REMOVED
- Item: ITEM_LOGIN_REQUIRED, ITEM_ERROR, NEW_ACCOUNTS_AVAILABLE, AUTHENTICATION
- Investments: INVESTMENTS_TRANSACTIONS_UPDATE, HOLDINGS_UPDATE
- Income: INCOME_VERIFICATION_UPDATED

CRITICAL: Always verify webhook signature before processing.
Plaid signs webhook body with your secret using HMAC-SHA256.
"""

from __future__ import annotations

import hmac
import hashlib
import base64
import json
import logging
from typing import Any, Callable, Optional

logger = logging.getLogger(__name__)


class PlaidWebhookVerifier:
    """Verifies Plaid webhook signatures.

    Two approaches:
    1. SDK's built-in: client.sandbox_verification_webhook() in Sandbox
    2. Manual HMAC-SHA256 verification (shown here, works in all envs)

    The Plaid-SDK provided validator is used in this implementation.
    """
    
    def __init__(self, secret: str | None = None):
        self.secret = secret or os.environ.get("PLAID_SECRET", "")
        if not self.secret:
            logger.warning("Plaid secret not configured for webhook verification")
    
    def verify(
        self,
        request_body: bytes,  # Raw bytes of request body
        plaid_verification_header: str,  # 'plaid-verification' header
    ) -> bool:
        """Verify Plaid webhook signature.

        Plaid computes signature as:
        Base64(HMAC-SHA256(secret, request_body + timestamp_in_header))

        The plaid-verification header format:
        t=1679945075,v0=abc123def456

        Args:
            request_body: Raw bytes from the HTTP request body.
            plaid_verification_header: Full value of 'plaid-verification' header.

        Returns:
            True if verified.

        Raises:
            ValueError: If verification fails.
        """
        if not self.secret:
            raise ValueError("Plaid secret required for webhook verification")
        
        if not plaid_verification_header:
            raise ValueError("Missing plaid-verification header")
        
        # Parse header: t=<timestamp>,v0=<signature>
        parts = plaid_verification_header.split(",")
        timestamp = None
        signature = None
        
        for part in parts:
            key, val = part.split("=", 1)
            if key == "t":
                timestamp = val
            elif key == "v0":
                signature = val
        
        if not timestamp or not signature:
            raise ValueError("Invalid plaid-verification header format")
        
        # Compute expected HMAC: secret, timestamp + "." + body
        # Payload = timestamp + "." + body_as_string
        payload = timestamp.encode("utf-8") + b"." + request_body
        
        # HMAC-SHA256
        mac = hmac.new(
            self.secret.encode("utf-8"),
            payload,
            hashlib.sha256,
        )
        
        expected_signature = base64.b64encode(mac.digest()).decode("utf-8")
        
        # Constant-time comparison to prevent timing attacks
        if hmac.compare_digest(signature, expected_signature):
            logger.info("Plaid webhook signature verified")
            return True
        else:
            logger.warning(
                "Plaid webhook signature mismatch: expected=%s received=%s",
                expected_signature, signature
            )
            raise ValueError("Plaid webhook signature verification failed")


class PlaidWebhookRouter:
    """Routes verified Plaid webhooks to handlers."""
    
    def __init__(self, verifier: PlaidWebhookVerifier | None = None):
        self.verifier = verifier or PlaidWebhookVerifier()
        self._handlers: dict[str, Callable[[dict[str, Any]], None]] = {}
    
    def on(
        self,
        webhook_type: str,
        webhook_code: str | None = None,
    ) -> Callable[[Callable], Callable]:
        """Decorator to register a webhook handler.

        Usage:
            @router.on("TRANSACTIONS", "DEFAULT_UPDATE")
            def handle_transactions_update(event):
                pass

            @router.on("ITEM")  # All ITEM webhooks
            def handle_any_item(event):
                pass
        """
        key = webhook_type
        if webhook_code:
            key = f"{webhook_type}:{webhook_code}"
        
        def decorator(handler: Callable[[dict[str, Any]], None]) -> Callable[[dict[str, Any]], None]:
            self._handlers[key] = handler
            return handler
        return decorator
    
    def verify_and_dispatch(
        self,
        request_body: bytes,
        plaid_verification_header: str,
    ) -> bool:
        """Verify signature and dispatch webhook.

        Returns:
            True if a handler was found and called.
        """
        # Step 1: VERIFY first
        self.verifier.verify(request_body, plaid_verification_header)
        
        # Step 2: Parse JSON
        event = json.loads(request_body.decode("utf-8"))
        
        webhook_type = event.get("webhook_type")  # TRANSACTIONS, ITEM, etc.
        webhook_code = event.get("webhook_code")  # DEFAULT_UPDATE, etc.
        item_id = event.get("item_id")
        
        logger.info(
            "Plaid webhook: type=%s code=%s item=%s",
            webhook_type, webhook_code, item_id
        )
        
        # Try specific handler first (type:code)
        specific_key = f"{webhook_type}:{webhook_code}"
        handler = self._handlers.get(specific_key)
        
        # Fall back to general handler (type only)
        if handler is None:
            handler = self._handlers.get(webhook_type)
        
        if handler:
            try:
                handler(event)
                return True
            except Exception:
                logger.exception(
                    "Plaid webhook handler failed for %s:%s",
                    webhook_type, webhook_code
                )
                raise
        
        logger.warning("No handler found for Plaid webhook: %s:%s", webhook_type, webhook_code)
        return False


# Initialize router
plaid_webhook_router = PlaidWebhookRouter()


@plaid_webhook_router.on("TRANSACTIONS", "DEFAULT_UPDATE")
def on_transactions_default_update(event: dict[str, Any]) -> None:
    """Handle TRANSACTIONS DEFAULT_UPDATE.

    Fires when new transactions are available (or have changed).
    Call transactions_sync with your stored cursor to get changes.

    Event:
        webhook_type: "TRANSACTIONS"
        webhook_code: "DEFAULT_UPDATE"
        item_id: "..."
        new_transactions: 5 (count since last webhook)
        error: null or error object
    """
    item_id = event.get("item_id")
    new_count = event.get("new_transactions", 0)
    
    logger.info(
        "Plaid: %d new transactions available for item %s",
        new_count, item_id
    )
    
    # ✅ Do this:
    # 1. Look up access_token for this item_id in your DB
    # 2. Look up stored cursor for this item_id
    # 3. Call transactions_sync(access_token, cursor)
    # 4. Process added/modified/removed transactions
    # 5. Update stored cursor to next_cursor
    # 6. Repeat while has_more


@plaid_webhook_router.on("ITEM", "LOGIN_REQUIRED")
def on_item_login_required(event: dict[str, Any]) -> None:
    """Handle ITEM LOGIN_REQUIRED (bank needs relink).

    Fires when Plaid can no longer access the bank — user changed password,
    MFA requirement changed, bank updated login flow.

    Action: Create Link token in "update mode" and ask user to relink.
    """
    item_id = event.get("item_id")
    logger.warning("Plaid Item needs relink: item_id=%s", item_id)
    
    # ✅ Do this:
    # 1. Mark user in your DB as "needs bank relink"
    # 2. When user logs in, show: "Your bank needs to be reconnected"
    # 3. Call create_link_token with access_token=... to enter update mode
    # 4. Link opens in "update credentials" mode automatically


@plaid_webhook_router.on("TRANSACTIONS", "TRANSACTIONS_REMOVED")
def on_transactions_removed(event: dict[str, Any]) -> None:
    """Handle TRANSACTIONS TRANSACTIONS_REMOVED.

    Fires when transactions are removed (bank corrected data, merchant
    updated transaction, etc.).

    Event includes list of removed_transaction_ids.
    """
    item_id = event.get("item_id")
    removed_ids = event.get("removed_transactions", [])
    
    logger.info(
        "Plaid: %d transactions removed for item %s",
        len(removed_ids), item_id
    )
    
    # ✅ Do this:
    # Delete or mark as removed these transaction_ids in your DB


@plaid_webhook_router.on("ITEM", "NEW_ACCOUNTS_AVAILABLE")
def on_new_accounts_available(event: dict[str, Any]) -> None:
    """Handle ITEM NEW_ACCOUNTS_AVAILABLE.

    Fires when user added a new account at their bank, or when Plaid
    gains access to additional accounts that were previously hidden.

    Call accounts_get or auth_get to see new accounts.
    """
    item_id = event.get("item_id")
    logger.info("Plaid: new accounts available for item %s", item_id)
```

---

## Constraints

### MUST DO

- Use `plaid-python` official SDK (not raw requests)
- Use modern Link flow: `link_token_create` → frontend Link → `public_token_exchange`
- Store `access_token` encrypted at rest; treat like API keys/passwords
- Use cursor-based `transactions_sync` (v2), not old `transactions_get` (v1)
- Verify webhook signatures via HMAC-SHA256 comparison
- Use constant-time comparison (`hmac.compare_digest`) for HMAC verification
- Handle `ITEM_LOGIN_REQUIRED` webhook with Link update mode for relinking
- Separate Sandbox/Development/Production credentials (each has different secret)
- Pass your persistent `client_user_id` in link_token_create user field

### MUST NOT DO

- NEVER hardcode `client_id` or `secret` in source code
- NEVER log, print, or expose `access_token` (treat like passwords)
- NEVER expose `access_token` to frontend code (public_token only!)
- NEVER use deprecated `public_key` Link flow; always use `link_token_create`
- NEVER skip webhook signature verification
- NEVER use wrong environment secret (sandbox vs development vs production)
- NEVER ignore `ITEM_LOGIN_REQUIRED` webhooks (Item will stop syncing)
- NEVER reuse cursors incorrectly in `transactions_sync` (always use `next_cursor`)
- NEVER store raw bank account/routing numbers without encryption (Auth product)

---

## Output Template

When implementing Plaid integrations, produce:

1. **Client Factory** — Environment-based PlaidApi with multi-environment support
2. **Link Flow** — `link_token_create` + `public_token_exchange` + secure access_token storage
3. **Error Parser** — Structured `ApiException` parsing with error_type/error_code
4. **Transaction Sync** — Cursor-based `transactions_sync` with has_more pagination
5. **Webhook Handler** — HMAC signature verification + router for TRANSACTIONS/ITEM webhooks
6. **Product Functions** — `auth_get`, `identity_get`, `accounts_balance_get` wrappers

---

## Related Skills

| Skill | Purpose |
|-------|---------|
| `coding-stripe-api` | Stripe for ACH payments after Plaid Auth gets account numbers |
| `coding-shopify-api` | Shopify for ecommerce with bank payout data |
| `coding-paypal-api` | PayPal for alternative payment methods |
| `coding-braintree-api` | Braintree marketplace payments |

---

## Live References

| Resource | URL |
|----------|-----|
| Plaid Python SDK | https://github.com/plaid/plaid-python |
| Plaid API Reference | https://plaid.com/docs/api/ |
| Link Token Flow | https://plaid.com/docs/link/ |
| Auth API | https://plaid.com/docs/api/products/auth/ |
| Transactions Sync | https://plaid.com/docs/api/products/transactions/#transactionssync |
| Identity API | https://plaid.com/docs/api/products/identity/ |
| Investments API | https://plaid.com/docs/api/products/investments/ |
| Webhook Verification | https://plaid.com/docs/api/webhooks/webhook-verification/ |
| Error Codes | https://plaid.com/docs/errors/ |
| Plaid Dashboard | https://dashboard.plaid.com/ |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.