Skip to content
Back to skills

Plaid Income

ASecurity

Implements income verification features via the Plaid API (IncomeGet, IncomeList) for employment-based and deposited income analysis in lending, underwriting, and financial applications.

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

Works with

  • cli
  • api

Security analysis

A100/100

Scanned June 12, 2026

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

Installs into .claude/skills of the current project.

Are you the author of Plaid Income?

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

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

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-income
description: Implements income verification features via the Plaid API (IncomeGet, IncomeList) for employment-based and deposited income analysis in lending, underwriting, and financial applications.
license: MIT
compatibility: opencode
metadata:
  version: "1.0.0"
  domain: payments
  triggers: plaid income, income verification, plaid income get, employment income, salary verification, deposited income, earning capacity analysis, underwriting income
  archetypes: tactical
  anti_triggers:
    - brainstorming
    - vague ideation
  response_profile:
    verbosity: low
    directive_strength: high
    abstraction_level: operational
  role: implementation
  scope: implementation
  output-format: code
  related-skills: plaid-authentication, plaid-transactions




---





# Plaid API Income Verification

Implements income verification workflows using the Plaid Income API to retrieve employment-based and deposited income data from linked bank accounts. Covers salary verification, income categorization, earning capacity estimation, and underwriting-grade analysis for lending decisions.

## TL;DR Checklist

- [ ] Always call `IncomeGet` after the user has linked an account with sufficient transaction history (>= 60 days recommended)
- [ ] Handle `INCOME_NOT_AVAILABLE` error gracefully — not all institutions provide income data
- [ ] Normalize amounts to annual figures using Plaid's `annualized_income` field for consistency
- [ ] Store income results encrypted — salary and employment data is sensitive PII

---

## When to Use

Use this skill when:

- You need verified income data for loan underwriting, mortgage pre-approval, or credit decisions
- Building rent affordability checks for rental applications
- Automating income verification to replace manual pay-stub submission in onboarding flows
- Estimating earning capacity from deposited income patterns for alternative credit scoring

## When NOT to Use

Avoid this skill for:

- Real-time payroll integration — use ADP, Gusto, or Rippling APIs directly
- Cryptocurrency or unbanked income sources — Plaid covers traditional bank accounts only
- Historical income analysis beyond the data available (Plaid provides recent deposits only)

---

## Core Workflow

1. **Complete Auth Flow** — User links their bank account via Plaid Link; obtain an `access_token`.
2. **Call IncomeGet** — Retrieve employment-based and deposited income summaries from Plaid.
3. **Validate Availability** — Handle cases where income data is not available for the institution or account type.
4. **Analyze & Store** — Process income data into structured format, apply normalization, and store with audit trail.

---

## Implementation Patterns

### Pattern 1: Income Retrieval and Analysis Service

```python
import plaid
from dataclasses import dataclass, field
from typing import Optional, List
from datetime import datetime


@dataclass
class IncomeEntry:
    """A single income record from Plaid."""
    income_type: str       # "employment" or "deposited"
    amount: float
    currency: str          # ISO 4217 (e.g., "USD")
    annualized_amount: Optional[float]
    frequency: Optional[str]
    employer_name: Optional[str]
    last_verified_date: Optional[datetime]


@dataclass
class IncomeVerificationResult:
    """Aggregated result of Plaid income verification."""
    employment_incomes: List[IncomeEntry] = field(default_factory=list)
    deposited_incomes: List[IncomeEntry] = field(default_factory=list)
    total_annualized_income: Optional[float] = None
    income_confidence: str  # "high", "medium", "low"
    errors: List[str] = field(default_factory=list)


class PlaidIncomeService:
    """Service for retrieving and analyzing user income data from Plaid."""

    def __init__(self, client_id: str, secret: str, environment: str = "sandbox"):
        self.client = plaid.Client(
            client_id=client_id,
            secret=secret,
            environment=getattr(plaid.Environment, environment),
        )

    def get_income(self, access_token: str) -> dict:
        """Fetch income data from Plaid for a linked item."""
        response = self.client.IncomeGet(access_token=access_token)
        return response

    @staticmethod
    def _parse_income_entry(data: dict) -> IncomeEntry:
        """Parse a single income entry dictionary into a typed object."""
        return IncomeEntry(
            income_type=data.get("income_type", "unknown"),
            amount=float(data.get("amount", 0)),
            currency=data.get("currency", "USD"),
            annualized_amount=float(data["annualized_income"]) if data.get("annualized_income") else None,
            frequency=data.get("frequency"),
            employer_name=data.get("employer", {}).get("name") if data.get("employer") else None,
            last_verified_date=datetime.fromisoformat(data["last_verified_at"]) if data.get("last_verified_at") else None,
        )

    def analyze_income(self, access_token: str) -> IncomeVerificationResult:
        """Retrieve and analyze income data for underwriting-grade analysis."""
        result = IncomeVerificationResult()

        try:
            response = self.get_income(access_token)
        except plaid.errors.PlaidApiError as e:
            if "INCOME_NOT_AVAILABLE" in str(e):
                result.errors.append("Income data is not available for this institution or account")
                result.income_confidence = "low"
                return result
            raise

        raw_data = response.get("incomes", [])
        for entry in raw_data:
            parsed = self._parse_income_entry(entry)
            if entry.get("income_type") == "employment":
                result.employment_incomes.append(parsed)
            elif entry.get("income_type") == "deposited":
                result.deposited_incomes.append(parsed)

        # Calculate total annualized income
        annual_amounts = [i.annualized_amount for i in raw_data if i.annualized_amount]
        if annual_amounts:
            result.total_annualized_income = sum(annual_amounts)

        # Determine confidence level
        if len(raw_data) >= 3:
            result.income_confidence = "high"
        elif len(raw_data) >= 1:
            result.income_confidence = "medium"
        else:
            result.income_confidence = "low"

        return result
```

### Pattern 2: Income Data Structure Reference

Plaid returns income data in this structure:

```python
{
    "incomes": [
        {
            "id": "income_123",
            "income_type": "employment",   # or "deposited"
            "amount": 6500.00,             # per-period amount
            "currency": "USD",
            "frequency": "monthly",        # monthly, biweekly, weekly, annually
            "annualized_income": 78000.00, # annualized figure
            "last_verified_at": "2025-01-15T10:30:00Z",
            "employer": {
                "name": "Acme Corp",
                "industry_code": "491112"  # NAICS code
            }
        }
    ]
}
```

### Pattern 3: Income Eligibility Checker

```python
@dataclass
class LendingCriteria:
    """Lending eligibility criteria for income-based decisions."""
    min_annual_income: float
    max_debt_to_income_ratio: float = 0.43
    required_employment_length_months: int = 6


class IncomeEligibilityChecker:
    """Evaluate lending eligibility against Plaid income data."""

    def __init__(self, criteria: LendingCriteria):
        self.criteria = criteria

    def check_eligibility(
        self,
        result: IncomeVerificationResult,
        monthly_debt_obligations: float = 0.0,
    ) -> dict:
        """Determine if user meets lending criteria based on income data."""
        annual_income = result.total_annualized_income or 0
        monthly_income = annual_income / 12.0
        dti_ratio = monthly_debt_obligations / monthly_income if monthly_income > 0 else float("inf")

        return {
            "meets_min_income": annual_income >= self.criteria.min_annual_income,
            "dti_ratio": round(dti_ratio, 4),
            "within_dti_limit": dti_ratio <= self.criteria.max_debt_to_income_ratio,
            "confidence_level": result.income_confidence,
            "approved": (
                annual_income >= self.criteria.min_annual_income
                and dti_ratio <= self.criteria.max_debt_to_income_ratio
                and result.income_confidence in ("high", "medium")
            ),
            "rejection_reasons": [
                reason
                for reason, passed in [
                    ("Insufficient income", annual_income >= self.criteria.min_annual_income),
                    ("Debt-to-income ratio too high", dti_ratio <= self.criteria.max_debt_to_income_ratio),
                    ("Low data confidence", result.income_confidence != "low"),
                ]
                if not passed
            ],
        }
```

---

## Constraints

### MUST DO
- Always check for `INCOME_NOT_AVAILABLE` errors before assuming data retrieval failure.
- Use the `annualized_income` field for consistent comparison across different pay frequencies.
- Store income data with timestamps indicating when it was last verified by Plaid.
- Apply encryption at rest for all stored income and employment data (AES-256 recommended).
- Log income verification attempts with confidence scores for compliance documentation.

### MUST NOT DO
- Use income data older than 90 days without re-fetching — bank deposits can change frequently.
- Store unencrypted income information in databases, caches, or log files.
- Make lending decisions based solely on `low` confidence results — require manual review.
- Share income data with third parties without explicit user consent and documented purpose.
- Assume all Plaid-linked institutions provide income data — always handle gracefully.

---

## Output Template

When implementing Plaid Income, output must contain:

1. **Income Retrieval Call** — `IncomeGet` invocation with error handling for unavailable data
2. **Analysis Logic** — Annualized income calculation and confidence scoring
3. **Eligibility Engine** — Criteria matching against lending or service requirements
4. **Storage Strategy** — Encryption method and retention policy for sensitive income data

---

## Related Skills

| Skill | Purpose |
|-------|---------|
| `plaid-authentication` | Initial bank account linking before income verification |
| `plaid-transactions` | Supplement income analysis with transaction-level detail |

---

## Live References

> Authoritative documentation links for this skill's domain. The model follows markdown links to resolve external references and inline content.

- [Plaid Income API Documentation](https://plaid.com/docs/api/products/income/)
- [Plaid Auth & Link Token Flow](https://plaid.com/docs/auth/)
- [Income Verification for Lending](https://plaid.com/docs/income/underwriting/)
- [Plaid Employment Data Reference](https://plaid.com/docs/api/products/income/#incomeget)
- [Plaid Developer Best Practices](https://plaid.com/docs/security-best-practices/)

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.