← Back to blog
·7 min read

AI Tax Verification Agent: ITR, Form 16 & Bank Statements

Build an AI agent that cross-checks ITR, Form 16, and bank statements to verify income for Indian lending and onboarding. TypeScript guide.

tax verificationITR parsingForm 16income verificationai agentindian fintechdocument extractionbank statement

Income verification is the backbone of lending, onboarding, and compliance in Indian fintech. Yet most systems still rely on manual document checks — an analyst downloads three PDFs, opens each one, and compares numbers by eye. It is slow, error-prone, and impossible to scale.

A better approach: give your AI agent the ability to read ITR acknowledgements, Form 16 certificates, and bank statements simultaneously, then flag any discrepancy automatically. This guide shows you how to build exactly that using Lekha's document extraction API.

Why Cross-Referencing Three Documents Matters

Each document tells a different slice of the income story:

| Document | What it proves | Weakness | | ------------------- | -------------------------------------- | ------------------------------- | | ITR acknowledgement | Self-declared income to the government | Can be fabricated or amended | | Form 16 | Employer-certified TDS deductions | Only covers salaried income | | Bank statement | Actual cash flow | Does not show off-market income |

No single document is sufficient. A borrower could declare ₹12 LPA on their ITR but receive only ₹8 LPA as credited salary — a ₹4 LPA gap worth knowing before you underwrite a loan. Cross-referencing all three closes this gap in seconds.

Prerequisites

Install the Lekha SDK and set your API key:

bun add lekha
export LEKHA_API_KEY=lk_live_your_key_here

Get your API key at lekhadev.com/playground.

Step 1: Extract ITR Data

Lekha parses ITR acknowledgement PDFs into structured JSON — gross total income, total tax paid, assessment year, PAN, and more.

import Lekha from "lekha";
import { readFileSync } from "fs";

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

async function extractITR(filePath: string) { const file = readFileSync(filePath); const result = await lekha.extract({ document: file, filename: "itr_acknowledgement.pdf", type: "itr", });

return { pan: result.data.pan, assessmentYear: result.data.assessment_year, grossTotalIncome: result.data.gross_total_income, totalTaxPaid: result.data.total_tax_paid, filingDate: result.data.filing_date, }; }

The gross_total_income field is returned as a number in rupees — no string parsing needed.

Step 2: Extract Form 16 Data

Form 16 is issued by employers and contains the TDS certificate alongside a salary breakdown. Lekha extracts both Part A (TDS details) and Part B (salary components):

async function extractForm16(filePath: string) {
  const file = readFileSync(filePath);
  const result = await lekha.extract({
    document: file,
    filename: "form16.pdf",
    type: "form16",
  });

return { employerName: result.data.employer_name, employerTAN: result.data.employer_tan, employeePAN: result.data.employee_pan, grossSalary: result.data.gross_salary, taxableIncome: result.data.taxable_income, totalTDSDeducted: result.data.total_tds_deducted, financialYear: result.data.financial_year, }; }

Step 3: Extract Bank Statement Data

Six to twelve months of bank statements reveal the real monthly cash flow — salary credits, recurring debits, and any irregular large deposits:

async function extractBankStatement(filePath: string) {
  const file = readFileSync(filePath);
  const result = await lekha.extract({
    document: file,
    filename: "bank_statement.pdf",
    type: "bank_statement",
  });

const transactions = result.data.transactions ?? [];

// Sum all salary credits (Lekha tags these as category: 'salary') const salaryCreditTotal = transactions .filter((t) => t.category === "salary" && t.type === "credit") .reduce((sum, t) => sum + t.amount, 0);

const monthsInStatement = result.data.statement_period_months ?? 1;

return { accountHolder: result.data.account_holder_name, bank: result.data.bank_name, averageMonthlySalaryCredit: salaryCreditTotal / monthsInStatement, annualisedSalaryFromBank: (salaryCreditTotal / monthsInStatement) * 12, statementPeriod: result.data.statement_period, }; }

Step 4: Build the Verification Agent

Now assemble the three extractors into a single verification function that computes a discrepancy score:

interface VerificationResult {
  pan: string;
  itrGrossIncome: number;
  form16GrossSalary: number;
  bankAnnualisedSalary: number;
  itrForm16Gap: number;
  itrBankGap: number;
  form16BankGap: number;
  verdict: "PASS" | "REVIEW" | "FAIL";
  flags: string[];
}

const TOLERANCE_PERCENT = 10; // allow ±10% variance

function pctDiff(a: number, b: number): number { if (a === 0) return 0; return Math.abs((a - b) / a) * 100; }

async function runTaxVerification( itrPath: string, form16Path: string, bankPath: string, ): Promise { const [itr, form16, bank] = await Promise.all([ extractITR(itrPath), extractForm16(form16Path), extractBankStatement(bankPath), ]);

const flags: string[] = [];

// PAN consistency check if (itr.pan !== form16.employeePAN) { flags.push(PAN mismatch: ITR=${itr.pan}, Form16=${form16.employeePAN}); }

const itrForm16Gap = pctDiff(itr.grossTotalIncome, form16.grossSalary); const itrBankGap = pctDiff( itr.grossTotalIncome, bank.annualisedSalaryFromBank, ); const form16BankGap = pctDiff( form16.grossSalary, bank.annualisedSalaryFromBank, );

if (itrForm16Gap > TOLERANCE_PERCENT) { flags.push( ITR vs Form16 income gap: ${itrForm16Gap.toFixed(1)}% — ITR ₹${itr.grossTotalIncome.toLocaleString("en-IN")}, Form16 ₹${form16.grossSalary.toLocaleString("en-IN")}, ); }

if (itrBankGap > TOLERANCE_PERCENT) { flags.push( ITR vs Bank income gap: ${itrBankGap.toFixed(1)}% — ITR ₹${itr.grossTotalIncome.toLocaleString("en-IN")}, Bank ₹${bank.annualisedSalaryFromBank.toLocaleString("en-IN")}, ); }

if (form16BankGap > TOLERANCE_PERCENT) { flags.push( Form16 vs Bank income gap: ${form16BankGap.toFixed(1)}% — Form16 ₹${form16.grossSalary.toLocaleString("en-IN")}, Bank ₹${bank.annualisedSalaryFromBank.toLocaleString("en-IN")}, ); }

const verdict = flags.length === 0 ? "PASS" : flags.some( (f) => f.includes("PAN mismatch") || parseFloat(f.match(/(\d+\.\d+)%/)?.[1] ?? "0") > 25, ) ? "FAIL" : "REVIEW";

return { pan: itr.pan, itrGrossIncome: itr.grossTotalIncome, form16GrossSalary: form16.grossSalary, bankAnnualisedSalary: bank.annualisedSalaryFromBank, itrForm16Gap, itrBankGap, form16BankGap, verdict, flags, }; }

All three extractions run in parallel via Promise.all — the whole pipeline typically completes in under five seconds regardless of document length.

Step 5: Run the Agent and Inspect Output

const report = await runTaxVerification(
  "documents/itr_ack.pdf",
  "documents/form16_fy2526.pdf",
  "documents/hdfc_6months.pdf",
);

console.log("Verification Report"); console.log("==================="); console.log(PAN: ${report.pan}); console.log( ITR Gross Income: ₹${report.itrGrossIncome.toLocaleString("en-IN")}, ); console.log( Form 16 Gross Salary: ₹${report.form16GrossSalary.toLocaleString("en-IN")}, ); console.log( Bank Annualised: ₹${report.bankAnnualisedSalary.toLocaleString("en-IN")}, ); console.log(Verdict: ${report.verdict}); if (report.flags.length > 0) { console.log("\nFlags:"); report.flags.forEach((f) => console.log( ⚠ ${f})); }

A PASS result looks like this:

Verification Report
===================
PAN: ABCDE1234F
ITR Gross Income:      ₹14,40,000
Form 16 Gross Salary:  ₹14,55,000
Bank Annualised:       ₹13,92,000
Verdict: PASS

A FAIL result might flag a 34% gap between ITR and bank credits — a common pattern when someone has inflated their ITR to qualify for a larger loan.

Handling Edge Cases

Multiple employers in a year: A borrower who switched jobs will have two Form 16 certificates. Collect both, sum their gross_salary fields, and compare the combined total against ITR. Part-year bank statements: If the statement covers fewer than six months, the annualised figure has higher variance. Cap TOLERANCE_PERCENT at 15% for statements under three months. Freelancers and consultants: Form 16 is not applicable — they receive Form 16A (TDS certificate for non-salary income). Lekha parses Form 16A under the same form16 type; adjust your logic to skip the employer salary cross-check and focus on the ITR-to-bank comparison instead. Multiple bank accounts: Run extractBankStatement on each account and sum the salary credits before annualising.

Deploying as an API Endpoint

Wrap the agent in an API route for use from your underwriting or onboarding flow:

// POST /api/verify-income
export async function POST(request: Request) {
  const formData = await request.formData();
  const itrFile = formData.get("itr") as File;
  const form16File = formData.get("form16") as File;
  const bankFile = formData.get("bank") as File;

const [itrBuffer, form16Buffer, bankBuffer] = await Promise.all([ itrFile.arrayBuffer(), form16File.arrayBuffer(), bankFile.arrayBuffer(), ]);

const [itr, form16, bank] = await Promise.all([ lekha.extract({ document: Buffer.from(itrBuffer), filename: itrFile.name, type: "itr", }), lekha.extract({ document: Buffer.from(form16Buffer), filename: form16File.name, type: "form16", }), lekha.extract({ document: Buffer.from(bankBuffer), filename: bankFile.name, type: "bank_statement", }), ]);

// ... run verification logic ...

return Response.json({ success: true, data: report }); }

Documents are processed in memory only — nothing is written to disk — which keeps the pipeline DPDP-compliant from day one. See lekhadev.com/docs for the full API reference.

FAQ

What is the typical turnaround time for a three-document verification? In production, the Lekha extraction API processes each document in one to three seconds. With Promise.all, all three run concurrently, so the full pipeline completes in roughly the time it takes to process the slowest document — typically under five seconds end-to-end. Does Lekha support ITR-V acknowledgements as well as XML returns? Lekha currently extracts ITR-V PDF acknowledgements issued by the Income Tax Department. XML e-return files are not yet supported; if you have them, read the field directly from the XML before passing it to your verification logic. How should I handle a borrower who has no Form 16 (self-employed)? Skip the Form 16 step entirely. Cross-check ITR gross income against the annualised bank credits, and increase your tolerance threshold slightly since business income is less regular than salary. You can also parse the borrower's GST return via Lekha to corroborate their declared turnover. Can I use this for AY 2024–25 documents? Yes. Lekha's ITR and Form 16 extractors handle documents from AY 2019–20 onwards, including the revised Form 16 format introduced after the 2019 salary structure changes.

Manual income verification takes 20–40 minutes per application and still misses cross-document inconsistencies. This agent does it in under 10 seconds and surfaces every flag automatically.

Try it yourself at lekhadev.com/playground — upload your sample documents and see the structured output immediately. When you are ready to integrate, sign up at lekhadev.com and start with the free tier.