← Back to blog
·10 min read

Parsing Bank of India Statements with AI

Parse Bank of India PDF statements into clean structured JSON using AI. Handles BOI net banking exports, passbook scans, and regional formats. Full TypeScript guide.

bank of indiabank statement parserboipublic sector bankai extractionindian fintechtypescriptfinancial documents

Bank of India (BOI) is one of India's oldest nationalised banks — founded in 1906 and now serving over 65 million customers across 5,000+ domestic branches. If you're building a lending platform, income verification workflow, or financial agent for a broad Indian audience, BOI statements will land in your document queue regularly.

The problem is that BOI statements come in several PDF layouts that differ by branch, account type, and whether the customer used net banking, the BOI Mobile app, or requested a printed passbook. A regex parser or column-splitter will work for one variant and silently misread another. This guide shows you how to extract clean, structured JSON from any BOI statement in seconds using Lekha — no format detection, no custom parsers, no maintenance burden.

Why Bank of India Statements Are Hard to Parse

BOI's core banking system (Finacle) generates PDFs that differ meaningfully across channels:

| Source | Typical characteristics | | --------------------------------- | ---------------------------------------------------------------- | | Net banking portal (Star Connect) | Multi-page, portrait layout, running balance column | | BOI Mobile app | Condensed single-page export, abbreviated narrations | | Branch-requested statement | Header-heavy, sometimes printed to PDF from a dot-matrix printer | | Passbook scan | Image PDF, no selectable text, variable scan quality |

Beyond layout differences, BOI statements share several parsing pitfalls:

Abbreviated narration codes. BOI uses internal shorthand like NEFTIN, IMPSOUT, UPIDR, UPICR, CDWATM, CLGCHQ, and OWTINT (outward interest) without a lookup table in the PDF. A parser that forwards raw narrations to a downstream system produces noisy, unusable data for transaction classification. Balance-forward rows at page breaks. Long statements insert "Carried Forward" and "Brought Forward" rows with the running balance. Naive parsers treat these as transactions and double-count the balance amount as a debit or credit. Regional language headers. Branch-printed statements often render account holder names, branch addresses, and nominee details in Devanagari or regional scripts (Marathi, Tamil, Telugu). Fixed-position text extraction breaks the moment it encounters non-Latin characters. Non-uniform date formats. BOI PDFs mix DD/MM/YYYY, DD-MMM-YYYY (e.g., 07-JAN-2026), and occasionally YYYYMMDD in the reference columns. Any downstream code that assumes one format will produce wrong dates.

Lekha's vision AI layer reads documents the way a human analyst does — understanding layout, language, and context — so none of these variations require custom handling on your end.

Quick Start: Parse a BOI Statement

Get your free API key at lekhadev.com and call the extract endpoint:

import fs from "fs";

const pdf = fs.readFileSync("boi-statement.pdf"); const base64 = pdf.toString("base64");

const response = await fetch("https://api.lekhadev.com/v1/extract", { method: "POST", headers: { Authorization: Bearer ${process.env.LEKHA_API_KEY}, "Content-Type": "application/json", }, body: JSON.stringify({ file: base64, type: "bank_statement", }), });

const { data } = await response.json(); console.log(data);

A typical BOI statement response:

{
  "bank": "Bank of India",
  "account_number": "XXXX XXXX 2341",
  "account_type": "savings",
  "account_holder": "Priya Ramachandran",
  "ifsc": "BKID0005412",
  "branch": "Chennai Anna Salai Branch",
  "statement_period": {
    "from": "2026-01-01",
    "to": "2026-03-31"
  },
  "opening_balance": 18450.0,
  "closing_balance": 26310.5,
  "currency": "INR",
  "transactions": [
    {
      "date": "2026-01-05",
      "description": "UPICR/PHONEPE/9876543210/Suresh Kumar",
      "type": "credit",
      "amount": 3000.0,
      "balance": 21450.0,
      "reference": "426509103821"
    },
    {
      "date": "2026-01-10",
      "description": "NEFTIN/HDFC0001234/Acme Pvt Ltd/Salary Jan 2026",
      "type": "credit",
      "amount": 45000.0,
      "balance": 66450.0,
      "reference": "N010260000012345"
    },
    {
      "date": "2026-01-15",
      "description": "CDWATM/BOI ATM ANNA SALAI",
      "type": "debit",
      "amount": 10000.0,
      "balance": 56450.0,
      "reference": "ATM0115000123"
    }
  ]
}

Dates are ISO 8601, amounts are numbers, and balance-forward rows are stripped. The narration codes (UPICR, NEFTIN, CDWATM) are preserved exactly as in the PDF so you can run your own classification logic on them.

Build a Reusable BOI Parser Module

Here's a typed TypeScript module with helper functions for the most common lending and KYC use cases:

// lib/boi-parser.ts
interface Transaction {
  date: string; // ISO 8601: YYYY-MM-DD
  description: string;
  type: "credit" | "debit";
  amount: number;
  balance: number;
  reference?: string;
}

interface BOIStatement { bank: string; account_number: string; account_type: string; account_holder: string; ifsc: string; branch: string; statement_period: { from: string; to: string }; opening_balance: number; closing_balance: number; currency: string; transactions: Transaction[]; }

export async function parseBOIStatement( pdfBuffer: Buffer, ): Promise { const response = await fetch("https://api.lekhadev.com/v1/extract", { method: "POST", headers: { Authorization: Bearer ${process.env.LEKHA_API_KEY}, "Content-Type": "application/json", }, body: JSON.stringify({ file: pdfBuffer.toString("base64"), type: "bank_statement", }), });

if (!response.ok) { const err = await response.json(); throw new Error( Lekha API error: ${err.error?.message ?? response.statusText}, ); }

const { data } = await response.json(); return data as BOIStatement; }

export function monthlySummary( stmt: BOIStatement, ): Record { const result: Record< string, { credits: number; debits: number; net: number } > = {};

for (const txn of stmt.transactions) { const month = txn.date.slice(0, 7); // "YYYY-MM" if (!result[month]) result[month] = { credits: 0, debits: 0, net: 0 };

if (txn.type === "credit") { result[month].credits += txn.amount; } else { result[month].debits += txn.amount; } result[month].net = result[month].credits - result[month].debits; }

return result; }

export function averageMonthlyBalance(stmt: BOIStatement): number { if (stmt.transactions.length === 0) return stmt.closing_balance; const total = stmt.transactions.reduce((sum, t) => sum + t.balance, 0); return Math.round(total / stmt.transactions.length); }

export function salaryCredits(stmt: BOIStatement): Transaction[] { // BOI salary narrations typically contain NEFTIN + employer name, or SALARY const pattern = /salary|neftin.salary|neftin.payroll|sal\s*cr/i; return stmt.transactions.filter( (t) => t.type === "credit" && pattern.test(t.description), ); }

export function upiTransactions(stmt: BOIStatement): Transaction[] { return stmt.transactions.filter((t) => /^(upicr|upidr|upi)/i.test(t.description), ); }

Income Verification Use Case

A common workflow for lending fintechs is to verify that a borrower has consistent salary income before approval. Here's an end-to-end income verification function:

// lib/income-verify.ts
import { parseBOIStatement, salaryCredits, monthlySummary } from "./boi-parser";

interface IncomeReport { verified: boolean; months_with_salary: number; average_salary: number; salary_transactions: Array<{ date: string; amount: number; description: string; }>; average_monthly_balance: number; verdict: string; }

export async function verifyIncome( statementBuffers: Buffer[], minimumSalary: number, ): Promise { const statements = await Promise.all(statementBuffers.map(parseBOIStatement));

const allSalaryTxns = statements.flatMap(salaryCredits);

// Count distinct months with at least one salary credit const salaryMonths = new Set(allSalaryTxns.map((t) => t.date.slice(0, 7))); const avgSalary = allSalaryTxns.length > 0 ? allSalaryTxns.reduce((s, t) => s + t.amount, 0) / allSalaryTxns.length : 0;

const avgBalance = statements.reduce((s, stmt) => { if (stmt.transactions.length === 0) return s + stmt.closing_balance; return ( s + stmt.transactions.reduce((b, t) => b + t.balance, 0) / stmt.transactions.length ); }, 0) / statements.length;

const verified = salaryMonths.size >= statements.length && avgSalary >= minimumSalary;

return { verified, months_with_salary: salaryMonths.size, average_salary: Math.round(avgSalary), salary_transactions: allSalaryTxns.map((t) => ({ date: t.date, amount: t.amount, description: t.description, })), average_monthly_balance: Math.round(avgBalance), verdict: verified ? Salary of ₹${Math.round(avgSalary).toLocaleString("en-IN")} confirmed for ${salaryMonths.size} consecutive month(s). : Insufficient salary evidence. Found ${salaryMonths.size}/${statements.length} months; average ₹${Math.round(avgSalary).toLocaleString("en-IN")} vs required ₹${minimumSalary.toLocaleString("en-IN")}., }; }

Wire this into a Next.js API route:

// app/api/verify-income/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyIncome } from "@/lib/income-verify";

export async function POST(req: NextRequest) { const form = await req.formData(); const files = form.getAll("statements") as File[]; const minimumSalary = Number(form.get("min_salary") ?? 20000);

if (files.length === 0) { return NextResponse.json( { success: false, error: { code: "MISSING_FILES", message: "No statements uploaded." }, }, { status: 400 }, ); }

const buffers = await Promise.all( files.map((f) => f.arrayBuffer().then((ab) => Buffer.from(ab))), );

const report = await verifyIncome(buffers, minimumSalary); return NextResponse.json({ success: true, data: report }); }

BOI-Specific Edge Cases Lekha Handles

Star Connect vs BOI Mobile layouts. The Star Connect net banking portal generates a wide-table PDF with separate debit and credit amount columns. BOI Mobile produces a narrower export with a single signed amount column. Lekha normalises both into the same type: "credit" | "debit" plus amount schema, so your application code needs no conditional logic. Current and OD accounts. BOI current accounts and overdraft (OD) accounts can have negative balances when the overdraft limit is drawn. The closing_balance in the response can be negative — your downstream code should handle this; Lekha does not clamp it to zero. Interest and charges rows. BOI statements include quarterly interest credit rows (OWTINT), locker rental charges, cheque book issuance fees, and annual maintenance charges. These appear in the transactions array with their original narration codes so you can filter them out in loan income analysis. Multi-account consolidated statements. Some BOI customers have linked accounts (savings + PPF, or joint accounts on the same CIF). Lekha returns the primary account in the top-level fields and populates a linked_accounts array for any secondary accounts detected in the same PDF. Passbook image scans. BOI branch staff often scan physical passbooks. Lekha handles these image PDFs using the same vision AI pipeline — no separate endpoint or configuration needed.

Try any of these against the Lekha playground before writing integration code.

Testing Your Integration

// tests/boi.test.ts
import { describe, it, expect } from "vitest";
import {
  parseBOIStatement,
  salaryCredits,
  monthlySummary,
} from "../lib/boi-parser";
import fs from "fs";
import path from "path";

describe("BOI statement parser", () => { it("extracts transactions from a Star Connect export", async () => { const pdf = fs.readFileSync( path.join(__dirname, "fixtures/boi-savings-3mo.pdf"), ); const stmt = await parseBOIStatement(pdf);

expect(stmt.bank).toBe("Bank of India"); expect(stmt.transactions.length).toBeGreaterThan(0); expect(stmt.closing_balance).toBeTypeOf("number");

for (const txn of stmt.transactions) { expect(txn.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(txn.amount).toBeTypeOf("number"); expect(txn.amount).toBeGreaterThan(0); } });

it("computes monthly summary correctly", async () => { const pdf = fs.readFileSync( path.join(__dirname, "fixtures/boi-savings-3mo.pdf"), ); const stmt = await parseBOIStatement(pdf); const summary = monthlySummary(stmt);

for (const [, month] of Object.entries(summary)) { expect(month.credits).toBeGreaterThanOrEqual(0); expect(month.debits).toBeGreaterThanOrEqual(0); expect(month.net).toBe(month.credits - month.debits); } });

it("identifies salary credits in a salary account", async () => { const pdf = fs.readFileSync( path.join(__dirname, "fixtures/boi-salary-account.pdf"), ); const stmt = await parseBOIStatement(pdf); const credits = salaryCredits(stmt); expect(credits.length).toBeGreaterThanOrEqual(1); }); });

FAQ

Does Lekha support all BOI account types? Yes — savings, current, salary, PPF, NRE, NRO, and overdraft accounts. The account_type field in the response identifies which type was detected. OD accounts may return negative balances, which is expected. How does Lekha handle "Carried Forward" and "Brought Forward" rows? Lekha detects and strips page-break summary rows automatically. They never appear in the transactions array, so you won't accidentally count them as debits or credits. Can I parse a BOI passbook scan (image PDF) rather than a digital PDF? Yes. Send the base64-encoded image PDF to the same endpoint. Lekha's vision AI reads image-based documents as accurately as digital ones, with no separate code path required. What BOI narration codes does Lekha recognise? Lekha preserves narration codes as-is (NEFTIN, UPICR, CDWATM, etc.) in the description field and normalises the transaction direction into type: "credit" | "debit". There is no lookup table to maintain — you get the raw narration for your own classification logic. How many months of BOI statements can I process at once? Lekha processes one document per API call. For multi-month analysis, call the API in parallel (Promise.all) and aggregate the results client-side — the batch processing pattern is covered in the Lekha docs.

Next Steps

  • Test your BOI statement in the Lekha playground — paste your document and see the JSON output instantly, no code required
  • Browse the full API reference and response schema in the Lekha docs
  • Sign up for a free API key at lekhadev.com and start extracting in minutes
  • Lekha supports 28+ Indian bank formats out of the box. Whether your users bank with BOI, SBI, HDFC, ICICI, Canara, or any other major Indian bank, the same API call works for all of them — no per-bank configuration needed.