← Back to blog
·8 min read

Freelancer Income Verification Agent: Bank Statements + ITR

Verify freelancer income with AI: parse bank statements and ITR filings with Lekha to assess gig worker loan eligibility. TypeScript guide for NBFCs.

freelancer incomeincome verificationbank statement parseritr parsinggig economyloan eligibilityai agentnbfc

India has over 15 million freelancers and gig workers — and most of them struggle to get loans. Traditional lenders rely on salary slips and Form 16, documents that don't exist for someone who invoices five clients a month on Upwork or delivers groceries on Swiggy Instamart.

The fix isn't a policy change — it's a smarter document pipeline. Freelancers have bank statements, ITR filings, and GST invoices that collectively tell a clear income story. This guide shows you how to build an agent that reads those documents with Lekha and produces a structured income assessment, so your NBFC or lending platform can make a decision in seconds instead of days.

What We're Building

An AI agent that:

  • Accepts a bank statement PDF and an ITR PDF from a freelancer applicant
  • Parses both documents into structured JSON using Lekha
  • Cross-validates the income signals across both sources
  • Produces a IncomeAssessment object your underwriting logic can consume
  • No hallucinated numbers — every figure traces back to a parsed document field.

    Prerequisites

  • Node.js 18+ or Bun
  • A Lekha API key — get one free at lekhadev.com
  • TypeScript project (or plain JavaScript — just drop the types)
  • npm install @lekhadev/sdk
    

    or

    bun add @lekhadev/sdk

    Step 1: Parse the Bank Statement

    Lekha accepts PDFs and images. For bank statements, send the PDF buffer and specify "bank_statement" as the document type.

    import Lekha from "@lekhadev/sdk";
    import fs from "fs";
    

    const lekha = new Lekha({ apiKey: process.env.LEKHA_API_KEY! });

    async function parseBankStatement(filePath: string) { const buffer = fs.readFileSync(filePath);

    const result = await lekha.extract({ document: buffer, filename: "statement.pdf", documentType: "bank_statement", });

    if (!result.success) { throw new Error( Bank statement extraction failed: ${result.error.message}, ); }

    return result.data; }

    The returned data object includes transactions, account_holder, opening_balance, closing_balance, and period. Each transaction has date (ISO 8601), description, amount, type (credit | debit), and balance.

    Step 2: Parse the ITR Filing

    ITR PDFs from the Income Tax portal or a CA's software are structured differently from bank statements. Lekha handles the format detection automatically — just pass "itr" as the document type.

    async function parseITR(filePath: string) {
      const buffer = fs.readFileSync(filePath);
    

    const result = await lekha.extract({ document: buffer, filename: "itr.pdf", documentType: "itr", });

    if (!result.success) { throw new Error(ITR extraction failed: ${result.error.message}); }

    return result.data; }

    The ITR response includes assessment_year, gross_total_income, total_income, tax_payable, income_heads (salary, business, capital gains, other sources), and pan.

    Step 3: Build the Income Assessment Logic

    With both documents parsed, we can cross-validate and compute the key metrics lenders care about: average monthly income, income stability, and source diversity.

    interface IncomeAssessment {
      pan: string;
      assessmentYear: string;
      // From ITR
      itrGrossIncome: number;
      itrNetIncome: number;
      // From bank statement
      totalCredits: number;
      averageMonthlyCredit: number;
      creditMonthCount: number;
      largestMonthlyCredit: number;
      smallestMonthlyCredit: number;
      // Cross-validation
      incomeDiscrepancyPct: number; // How far bank credits diverge from ITR income
      stabilityScore: number; // 0-100: how consistent monthly credits are
      eligibleForLoan: boolean;
      flags: string[];
    }
    

    function computeMonthlyCredits( transactions: Transaction[], ): Map { const monthly = new Map();

    for (const tx of transactions) { if (tx.type !== "credit") continue; // Skip internal transfers (round numbers, own-account descriptions) if (isLikelyTransfer(tx)) continue;

    const month = tx.date.substring(0, 7); // "YYYY-MM" monthly.set(month, (monthly.get(month) ?? 0) + tx.amount); }

    return monthly; }

    function isLikelyTransfer(tx: Transaction): boolean { const desc = tx.description.toLowerCase(); return ( desc.includes("neft own") || desc.includes("self transfer") || desc.includes("sweep") || (tx.amount % 10000 === 0 && tx.amount > 50000) ); }

    function stdDev(values: number[]): number { const mean = values.reduce((a, b) => a + b, 0) / values.length; const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length; return Math.sqrt(variance); }

    function assessIncome( bankData: BankStatementData, itrData: ITRData, ): IncomeAssessment { const flags: string[] = []; const monthlyCredits = computeMonthlyCredits(bankData.transactions); const creditValues = Array.from(monthlyCredits.values());

    const totalCredits = creditValues.reduce((a, b) => a + b, 0); const averageMonthlyCredit = creditValues.length > 0 ? totalCredits / creditValues.length : 0; const largestMonthlyCredit = Math.max(...creditValues, 0); const smallestMonthlyCredit = Math.min(...creditValues, 0);

    // Stability: coefficient of variation inverted to 0-100 score const cv = averageMonthlyCredit > 0 ? stdDev(creditValues) / averageMonthlyCredit : 1; const stabilityScore = Math.max(0, Math.round((1 - cv) * 100));

    // Compare annualized bank credits to ITR gross income const annualizedBankIncome = averageMonthlyCredit * 12; const incomeDiscrepancyPct = itrData.gross_total_income > 0 ? (Math.abs(annualizedBankIncome - itrData.gross_total_income) / itrData.gross_total_income) * 100 : 100;

    if (incomeDiscrepancyPct > 30) { flags.push( High income discrepancy: bank annualized ₹${annualizedBankIncome.toFixed(0)} vs ITR ₹${itrData.gross_total_income}, ); }

    if (creditValues.length < 6) { flags.push( Only ${creditValues.length} months of credit history available, ); }

    if (stabilityScore < 40) { flags.push(Low income stability score: ${stabilityScore}/100); }

    const MIN_MONTHLY_INCOME = 25000; // configurable per product const eligibleForLoan = averageMonthlyCredit >= MIN_MONTHLY_INCOME && stabilityScore >= 40 && incomeDiscrepancyPct <= 40 && flags.length === 0;

    return { pan: itrData.pan, assessmentYear: itrData.assessment_year, itrGrossIncome: itrData.gross_total_income, itrNetIncome: itrData.total_income, totalCredits, averageMonthlyCredit, creditMonthCount: creditValues.length, largestMonthlyCredit, smallestMonthlyCredit, incomeDiscrepancyPct, stabilityScore, eligibleForLoan, flags, }; }

    Step 4: Wire It Into an Agent

    Combine the parsing steps into a single callable function that your API route, LangChain tool, or Claude tool_use handler can invoke.

    async function runFreelancerIncomeAgent(
      bankStatementPath: string,
      itrPath: string,
    ): Promise {
      const [bankData, itrData] = await Promise.all([
        parseBankStatement(bankStatementPath),
        parseITR(itrPath),
      ]);
    

    return assessIncome(bankData, itrData); }

    // Example usage const assessment = await runFreelancerIncomeAgent( "./uploads/applicant_bank.pdf", "./uploads/applicant_itr.pdf", );

    console.log(assessment); // { // pan: "ABCDE1234F", // assessmentYear: "2024-25", // itrGrossIncome: 1200000, // itrNetIncome: 960000, // averageMonthlyCredit: 98500, // stabilityScore: 72, // incomeDiscrepancyPct: 1.5, // eligibleForLoan: true, // flags: [] // }

    Try it interactively on the Lekha Playground before wiring it into your pipeline.

    Handling Edge Cases

    Missing months: Some freelancers have months with zero income. Don't drop these from the stability calculation — a zero is meaningful. The code above includes zeros from the full statement period. UPI credit noise: UPI-in credits on a current account often include personal transfers that inflate income. Use the isLikelyTransfer filter above, and extend it with descriptions specific to your applicant pool (Paytm self-transfer, PhonePe split, etc.). Multi-bank applicants: Freelancers sometimes split income across two accounts. Accept multiple bank statement uploads, merge the transactions arrays after deduplication, and run computeMonthlyCredits on the combined set. ITR vs bank year mismatch: If the bank statement covers April 2024 – March 2025 and the ITR is for AY 2024-25 (same period), the comparison is direct. Document this assumption in your UX — ask applicants to upload the ITR for the same financial year as the bank statement.

    Why Not Just Use the ITR Alone?

    ITR income is self-declared and filed annually. It tells you what a freelancer earned last year, but not whether they're earning right now. A bank statement from the last six months tells you about present-day cash flow. Together, they give you:

    | Signal | ITR | Bank Statement | | ---------------------------- | --- | -------------- | | Historical income (declared) | ✓ | — | | Current cash flow | — | ✓ | | Income regularity | — | ✓ | | Tax compliance | ✓ | — |

    Running both through Lekha and cross-validating the signals is how you build a reliable picture without a salary slip.

    Deploying in Production

    See the Lekha docs for:

  • Webhook callbacks for async extraction on large PDFs
  • Batch endpoints for processing multiple applicants in parallel
  • Retention policy: Lekha processes documents in memory and does not store them — compliant with DPDP Act requirements
  • For high-volume NBFC pipelines, run the two lekha.extract calls in parallel (as shown with Promise.all) to cut latency by roughly half.


    FAQ

    Can Lekha handle password-protected bank statement PDFs? Yes — pass the PDF password as the password field in the extract request. Most Indian banks let customers download unlocked statements from net banking; the password field is there for the ones that don't. What if a freelancer only has ITR and no bank statement? You can run the agent with just the ITR, but skip the cross-validation step and stability score. Flag the assessment as "single-source" and apply a more conservative eligibility threshold. Which ITR forms does Lekha support? Lekha parses ITR-1 (Sahaj), ITR-2, ITR-3, and ITR-4 (Sugam). ITR-4 is the most common for freelancers filing under the presumptive taxation scheme (Section 44ADA). How accurate is the income extraction from PDFs? Lekha uses vision AI rather than OCR, so it handles scanned documents, multi-column layouts, and mixed-language statements. For ITR PDFs downloaded from the income tax portal, accuracy is effectively 100% — the PDFs are machine-generated. For bank statements, accuracy averages above 98% across the 35+ formats Lekha supports.

    Freelancers deserve access to credit as much as salaried employees — they just need lenders who can read the right documents. Lekha gives you the parsing layer so you can focus on the underwriting logic.

    Ready to build? Sign up at lekhadev.com and get 100 free extractions to get started. Questions? Reach us at the docs or drop a message from the playground.