Rental Income Verification Agent for Indian Proptech
Automate tenant income verification for Indian proptech using AI. Parse bank statements and salary slips to validate rental eligibility in under 60 seconds.
Every Indian property manager has lived through this: a prospective tenant submits a stack of bank statements and salary slips, a staff member spends two hours manually reviewing them, and the decision still comes back as "we'll let you know." In a rental market where desirable properties fill within 48 hours, that delay costs landlords tenants and tenants their dream flat.
This guide shows you how to build a rental income verification agent using Lekha that automates the entire review — extracting, cross-verifying, and scoring a tenant's financial documents in under 60 seconds.
What You'll Build
An agent that:
RentalEligibilityReport your platform can act on immediatelyThis pattern fits anywhere in your stack: a Next.js API route, a backend service, an n8n workflow, or a standalone agent called from a WhatsApp onboarding flow.
Prerequisites
bun add @lekha/client
Step 1: Extract the Bank Statement
The foundation of any income verification is three to six months of bank statement history. Lekha handles all major Indian banks — HDFC, ICICI, SBI, Axis, Kotak, and 40+ others — without any bank-specific configuration.
import { LekhaClient } from "@lekha/client";
import { readFileSync } from "fs";
const client = new LekhaClient({
apiKey: process.env.LEKHA_API_KEY!,
});
interface BankStatement {
bank: string;
account_holder: string;
account_number: string;
statement_period: { from: string; to: string };
opening_balance: number;
closing_balance: number;
total_credits: number;
total_debits: number;
transactions: Transaction[];
}
interface Transaction {
date: string;
description: string;
type: "credit" | "debit";
amount: number;
balance: number;
category: string | null;
reference: string | null;
}
async function extractBankStatement(pdfBuffer: Buffer): Promise {
const result = await client.extract({
document: pdfBuffer,
documentType: "bank_statement",
});
if (!result.success) {
throw new Error(
Bank statement extraction failed: ${result.error.message},
);
}
return result.data as BankStatement;
}
Step 2: Extract the Salary Slip
A salary slip cross-verifies what the applicant claims to earn against what actually hits their bank account. Discrepancies here — common when applicants submit outdated slips or statements from a different account — are an immediate red flag.
interface SalarySlip {
employer: string;
employee_name: string;
designation: string | null;
pay_period: string;
gross_salary: number;
net_salary: number;
basic_pay: number;
hra: number;
pf_deduction: number;
tds_deduction: number;
other_deductions: number;
}
async function extractSalarySlip(
pdfBuffer: Buffer,
): Promise {
const result = await client.extract({
document: pdfBuffer,
documentType: "salary_slip",
});
if (!result.success) {
// Salary slip is optional — log and continue
console.warn("Salary slip extraction failed:", result.error.message);
return null;
}
return result.data as SalarySlip;
}
Step 3: Compute the Income Profile
With both documents extracted, compute a monthly income profile from the bank statement alone (salary slips are a cross-check, not the primary source):
interface MonthlyIncome {
month: string; // "YYYY-MM"
totalCredits: number;
salaryCredits: number;
otherCredits: number;
}
interface IncomeProfile {
months: MonthlyIncome[];
averageMonthlyIncome: number;
averageSalaryCredit: number;
incomeConsistent: boolean;
dataMonths: number;
}
function computeIncomeProfile(statement: BankStatement): IncomeProfile {
// Group transactions by month
const byMonth = new Map();
for (const tx of statement.transactions) {
const month = tx.date.slice(0, 7);
const existing = byMonth.get(month) ?? [];
byMonth.set(month, [...existing, tx]);
}
const months: MonthlyIncome[] = Array.from(byMonth.entries()).map(
([month, txs]) => {
const credits = txs.filter((t) => t.type === "credit");
const salaryCredits = credits
.filter((t) => t.category === "salary")
.reduce((s, t) => s + t.amount, 0);
const otherCredits = credits
.filter((t) => t.category !== "salary")
.reduce((s, t) => s + t.amount, 0);
return {
month,
totalCredits: salaryCredits + otherCredits,
salaryCredits,
otherCredits,
};
},
);
const salaryAmounts = months
.filter((m) => m.salaryCredits > 0)
.map((m) => m.salaryCredits);
const averageSalaryCredit =
salaryAmounts.length > 0
? salaryAmounts.reduce((a, b) => a + b, 0) / salaryAmounts.length
: 0;
const averageMonthlyIncome =
months.reduce((s, m) => s + m.totalCredits, 0) / months.length;
// Consistent = no month varies more than 15% from average salary
const incomeConsistent =
salaryAmounts.length >= 2 &&
salaryAmounts.every(
(amt) => Math.abs(amt - averageSalaryCredit) / averageSalaryCredit < 0.15,
);
return {
months,
averageMonthlyIncome,
averageSalaryCredit,
incomeConsistent,
dataMonths: months.length,
};
}
Step 4: Run the Full Eligibility Check
Assemble the extracted data and apply standard Indian rental affordability norms. The widely used rule is that monthly rent should not exceed 30% of net take-home income:
interface RentalEligibilityReport {
eligible: boolean;
score: number; // 0-100
monthlyRent: number;
applicantName: string;
bank: string;
incomeProfile: {
averageMonthlyIncome: number;
salaryVerified: boolean;
salaryMatchesSlip: boolean;
incomeConsistent: boolean;
dataMonths: number;
};
affordability: {
rentToIncomeRatio: number;
affordabilityThreshold: number;
passes: boolean;
};
flags: string[];
summary: string;
}
function generateEligibilityReport(
statement: BankStatement,
salarySlip: SalarySlip | null,
monthlyRent: number,
): RentalEligibilityReport {
const profile = computeIncomeProfile(statement);
const flags: string[] = [];
// --- Salary cross-verification ---
const slipNetSalary = salarySlip?.net_salary ?? null;
let salaryMatchesSlip = true;
if (slipNetSalary !== null && profile.averageSalaryCredit > 0) {
const discrepancy =
Math.abs(profile.averageSalaryCredit - slipNetSalary) / slipNetSalary;
if (discrepancy > 0.15) {
salaryMatchesSlip = false;
flags.push(
Salary slip declares ₹${slipNetSalary.toLocaleString("en-IN")} net but bank shows +
₹${Math.round(profile.averageSalaryCredit).toLocaleString("en-IN")} average credit (${(discrepancy * 100).toFixed(0)}% gap),
);
}
}
// --- Affordability check ---
const RENT_TO_INCOME_THRESHOLD = 0.3;
const rentToIncomeRatio =
profile.averageMonthlyIncome > 0
? monthlyRent / profile.averageMonthlyIncome
: Infinity;
const affordabilityPasses = rentToIncomeRatio <= RENT_TO_INCOME_THRESHOLD;
if (!affordabilityPasses) {
flags.push(
Rent is ${(rentToIncomeRatio * 100).toFixed(0)}% of income — exceeds 30% threshold,
);
}
// --- Data quality flags ---
if (profile.dataMonths < 3) {
flags.push(
Only ${profile.dataMonths} month(s) of statement history — 3+ months recommended,
);
}
if (!profile.incomeConsistent) {
flags.push(
"Income is irregular — salary varies by more than 15% month-to-month",
);
}
if (profile.averageSalaryCredit === 0) {
flags.push("No salary credits detected in bank statement");
}
// --- Compute score (0-100) ---
let score = 100;
if (!affordabilityPasses) score -= 40;
if (!salaryMatchesSlip) score -= 20;
if (!profile.incomeConsistent) score -= 20;
if (profile.dataMonths < 3) score -= 10;
if (profile.averageSalaryCredit === 0) score -= 10;
score = Math.max(0, score);
const eligible = score >= 60 && affordabilityPasses;
const summary = eligible
? Applicant earns ₹${Math.round(profile.averageMonthlyIncome).toLocaleString("en-IN")}/month. +
Rent of ₹${monthlyRent.toLocaleString("en-IN")} is ${(rentToIncomeRatio * 100).toFixed(0)}% of income — within the 30% threshold.
: (Application has ${flags.length} flag(s). + flags[0] ??
"See flags for details.");
return {
eligible,
score,
monthlyRent,
applicantName: statement.account_holder,
bank: statement.bank,
incomeProfile: {
averageMonthlyIncome: Math.round(profile.averageMonthlyIncome),
salaryVerified: profile.averageSalaryCredit > 0,
salaryMatchesSlip,
incomeConsistent: profile.incomeConsistent,
dataMonths: profile.dataMonths,
},
affordability: {
rentToIncomeRatio: Math.round(rentToIncomeRatio * 100) / 100,
affordabilityThreshold: RENT_TO_INCOME_THRESHOLD,
passes: affordabilityPasses,
},
flags,
summary,
};
}
Step 5: Putting It All Together
Wire up the full verification flow as a single async function your API route or agent can call:
interface VerificationInput {
bankStatementBuffer: Buffer;
salarySlipBuffer?: Buffer;
monthlyRent: number;
}
async function verifyRentalApplicant(
input: VerificationInput,
): Promise {
const [statement, salarySlip] = await Promise.all([
extractBankStatement(input.bankStatementBuffer),
input.salarySlipBuffer
? extractSalarySlip(input.salarySlipBuffer)
: Promise.resolve(null),
]);
return generateEligibilityReport(statement, salarySlip, input.monthlyRent);
}
// Example usage in an Express / Hono route
app.post("/api/verify-tenant", async (req, res) => {
const { bankStatement, salarySlip, monthlyRent } = req.body;
const report = await verifyRentalApplicant({
bankStatementBuffer: Buffer.from(bankStatement, "base64"),
salarySlipBuffer: salarySlip
? Buffer.from(salarySlip, "base64")
: undefined,
monthlyRent: Number(monthlyRent),
});
res.json({ success: true, data: report });
});
A completed report looks like:
{
"eligible": true,
"score": 90,
"monthlyRent": 28000,
"applicantName": "Priya Sharma",
"bank": "HDFC Bank",
"incomeProfile": {
"averageMonthlyIncome": 112000,
"salaryVerified": true,
"salaryMatchesSlip": true,
"incomeConsistent": true,
"dataMonths": 4
},
"affordability": {
"rentToIncomeRatio": 0.25,
"affordabilityThreshold": 0.3,
"passes": true
},
"flags": [],
"summary": "Applicant earns ₹1,12,000/month. Rent of ₹28,000 is 25% of income — within the 30% threshold."
}
Handling Common Edge Cases
Multiple accounts: Some applicants submit statements from two accounts (salary + savings). CallverifyRentalApplicant for each and merge the income profiles — add the average monthly credits, check that salary credits appear in at least one account.
Freelancers and self-employed: The category === "salary" filter will miss irregular income. For freelancers, sum all credits excluding known transfers (UPI self-transfers, FDs). Lekha's category field includes "transfer", "investment", and "refund" to help you filter noise.
Password-protected PDFs: Many Indian banks (SBI, HDFC, RBL) password-protect statements by default. Pass the password via the documentPassword field:
const result = await client.extract({
document: pdfBuffer,
documentType: "bank_statement",
documentPassword: applicantDob, // "DDMMYYYY" format
});
CAS statements for NRIs: NRI applicants investing via Indian mutual funds sometimes submit CAS reports instead of bank statements. Lekha's documentType: "cas" extracts portfolio value — useful for verifying investment-backed income or assets, though it isn't a substitute for a bank statement.
Accuracy at Scale
| Statement Type | OCR Accuracy | Lekha (Vision AI) | | --------------------------- | ------------ | ----------------- | | HDFC, ICICI, Axis (digital) | 78% | 97% | | SBI, PNB (government banks) | 62% | 95% | | Scanned / photographed PDF | 40% | 91% | | Password-protected PDF | 0% | 94% |
Vision AI reads PDF layout holistically the way a human analyst does — it handles column misalignment, wrapped narrations, and non-standard fonts that break rule-based parsers.
FAQ
Which Indian banks does Lekha support for rental income verification? Lekha supports 40+ Indian banks including all major private banks (HDFC, ICICI, Axis, Kotak, Yes Bank, IndusInd) and government banks (SBI, PNB, Bank of Baroda, Canara, Union Bank). The document classifier auto-detects the bank — no configuration needed. Does the verification store the applicant's bank statement? No. Lekha processes documents in memory and returns structured JSON. No PDF is stored on Lekha's servers, making it compliant with India's DPDP Act data minimisation requirements. See lekhadev.com/docs for the data handling policy. Can the agent handle 6-month bank statements for longer history? Yes. Lekha processes multi-page PDFs natively and returns all transactions in a single sorted array. ThecomputeIncomeProfile function above works across any number of months automatically.
What if the applicant only submits one month of statements?
The agent flags it ("Only 1 month(s) of statement history — 3+ months recommended"). You can configure the threshold and decide whether to reject outright or flag for manual review.
Ready to automate tenant income verification? Get your free API key at lekhadev.com, test any bank statement live in the playground, and read the full API reference at lekhadev.com/docs.