← Back to blog
·8 min read

Build a KYC Verification Agent with Lekha and Claude

Automate KYC document checks for PAN cards, Aadhaar, salary slips, and bank statements using Lekha's extraction API and Claude tool_use. Full TypeScript walkthrough.

kyc verificationai agentpan cardbank statementindian fintechclaude tool usedocument extractionidentity verification

KYC — Know Your Customer — is mandatory for every Indian fintech product: lending, wealth management, insurance, neobanks, crypto exchanges, and payment gateways. The traditional workflow is slow: a human agent downloads documents, cross-checks fields manually, and flags discrepancies. That loop takes 24–72 hours and costs money at scale.

This post shows you how to collapse that to under 10 seconds using an AI agent that reads documents with Lekha and reasons over them with Claude.

What a KYC Agent Actually Does

A KYC agent receives a customer's document bundle and answers three questions:

  • Identity match — Does the name on the PAN card match the name on the bank statement?
  • Income verification — Does the salary slip income match the stated income on the loan application?
  • Address consistency — Does the address on documents match each other and the customer's declared address?
  • The agent extracts structured JSON from each document, compares fields programmatically, and returns a verdict with confidence score and any discrepancies found. No human reviews documents unless the agent flags a mismatch.

    Architecture

    Customer upload (PDF/image bundle)
            ↓
       Lekha API  ←── extracts structured JSON per document
            ↓
      Claude (tool_use)  ←── reasons across documents, detects mismatches
            ↓
      KYC verdict JSON  →  your database / underwriting system
    

    The agent uses Claude's tool_use to call Lekha on demand. Claude decides which document to extract and what fields to compare — you don't hardcode the comparison logic.

    Prerequisites

    bun add @anthropic-ai/sdk
    

    You'll need:

  • A Lekha API key (lk_live_...)
  • An Anthropic API key
  • Step 1: Define the Lekha Tools

    Give Claude two tools: one to extract any document and one to compare fields across results.

    import Anthropic from "@anthropic-ai/sdk";
    

    const client = new Anthropic();

    const tools: Anthropic.Tool[] = [ { name: "extract_document", description: "Extract structured data from an Indian financial document (PAN card, Aadhaar, bank statement, salary slip, ITR). Returns JSON with all detected fields.", input_schema: { type: "object" as const, properties: { document_url: { type: "string", description: "URL of the document to extract (PDF or image)", }, document_type: { type: "string", enum: [ "pan_card", "aadhaar", "bank_statement", "salary_slip", "itr", "form_16", ], description: "Type of document being extracted", }, }, required: ["document_url", "document_type"], }, }, ];

    Step 2: Implement the Tool Handler

    interface ExtractDocumentInput {
      document_url: string;
      document_type: string;
    }
    

    async function extractDocument(input: ExtractDocumentInput): Promise { const response = await fetch("https://lekhadev.com/api/extract", { method: "POST", headers: { Authorization: Bearer ${process.env.LEKHA_API_KEY}, "Content-Type": "application/json", }, body: JSON.stringify({ url: input.document_url, type: input.document_type, }), });

    if (!response.ok) { const error = await response.json(); throw new Error(Lekha extraction failed: ${JSON.stringify(error)}); }

    const result = await response.json(); return result.data; }

    async function handleToolCall( toolName: string, toolInput: unknown, ): Promise { if (toolName === "extract_document") { const result = await extractDocument(toolInput as ExtractDocumentInput); return JSON.stringify(result); } throw new Error(Unknown tool: ${toolName}); }

    Step 3: Build the KYC Agent Loop

    interface KycBundle {
      pan_card_url: string;
      bank_statement_url: string;
      salary_slip_url: string;
      declared_name: string;
      declared_annual_income: number;
      declared_address: string;
    }
    

    interface KycVerdict { approved: boolean; confidence: number; checks: { name_match: boolean; income_verified: boolean; address_consistent: boolean; }; discrepancies: string[]; extracted_data: { pan?: unknown; bank_statement?: unknown; salary_slip?: unknown; }; }

      async function runKycAgent(bundle: KycBundle): Promise { const systemPrompt = You are a KYC verification agent for an Indian fintech company. You receive a bundle of customer documents and must verify:
    • Name consistency across all documents
    • Income verification against declared income
    • Address consistency

    Use the extract_document tool to read each document, then compare the fields. Return a final JSON verdict with: approved (boolean), confidence (0-1), checks object, and discrepancies array.

    Be strict on name mismatches — minor spelling differences (e.g. "Rajesh" vs "Rajesh Kumar") should be flagged as warnings, not failures. Transposed first/last names are a hard failure. Income within 15% of declared is acceptable. Address must match at least city and state.;

    const userMessage = Please verify KYC for this customer:

    Declared name: ${bundle.declared_name} Declared annual income: ₹${bundle.declared_annual_income.toLocaleString("en-IN")} Declared address: ${bundle.declared_address}

      Documents to verify:
    • PAN card: ${bundle.pan_card_url}
    • Bank statement (last 3 months): ${bundle.bank_statement_url}
    • Salary slip (last month): ${bundle.salary_slip_url}

    Extract all documents and return a KYC verdict as JSON.;

    const messages: Anthropic.MessageParam[] = [ { role: "user", content: userMessage }, ];

    // Agentic loop — Claude calls tools until it has enough data while (true) { const response = await client.messages.create({ model: "claude-opus-4-6", max_tokens: 4096, system: systemPrompt, tools, messages, });

    // Add assistant response to history messages.push({ role: "assistant", content: response.content });

    // Done — extract the final verdict if (response.stop_reason === "end_turn") { const textBlock = response.content.find((b) => b.type === "text"); if (!textBlock || textBlock.type !== "text") { throw new Error("Agent did not return a text verdict"); }

    // Parse JSON from Claude's response const jsonMatch = textBlock.text.match(/

    json\n([\s\S]*?)\n
    `/); const verdictJson = jsonMatch ? jsonMatch[1] : textBlock.text; return JSON.parse(verdictJson) as KycVerdict; }

    // Process tool calls if (response.stop_reason === "tool_use") { const toolResults: Anthropic.ToolResultBlockParam[] = [];

    for (const block of response.content) { if (block.type !== "tool_use") continue;

    try { const result = await handleToolCall(block.name, block.input); toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result, }); } catch (error) { toolResults.push({ type: "tool_result", tool_use_id: block.id, content: Error: ${error instanceof Error ? error.message : "Unknown error"}, is_error: true, }); } }

    messages.push({ role: "user", content: toolResults }); } } }

    
    

    Step 4: Use the Agent

    typescript const verdict = await runKycAgent({ pan_card_url: "https://storage.example.com/customers/c123/pan.pdf", bank_statement_url: "https://storage.example.com/customers/c123/hdfc-stmt.pdf", salary_slip_url: "https://storage.example.com/customers/c123/salary-apr.pdf", declared_name: "Priya Sharma", declared_annual_income: 1200000, // ₹12 LPA declared_address: "Koramangala, Bengaluru, Karnataka", });

    console.log(verdict); // { // approved: true, // confidence: 0.94, // checks: { // name_match: true, // income_verified: true, // address_consistent: true // }, // discrepancies: [], // extracted_data: { pan: {...}, bank_statement: {...}, salary_slip: {...} } // }

    
    A failed KYC example looks like:

    json { "approved": false, "confidence": 0.88, "checks": { "name_match": false, "income_verified": true, "address_consistent": true }, "discrepancies": [ "Name on PAN card is 'Priya S Sharma' but salary slip shows 'Priya Sharma' — middle name present on PAN only. Manual review recommended.", "Bank statement name reads 'P SHARMA' — abbreviated format, cannot confirm match." ] }
    
    

    Handling Edge Cases

    Password-protected PDFs — Lekha handles password-protected bank statement PDFs (most Indian banks do this). Pass the password in the request:
    typescript body: JSON.stringify({ url: input.document_url, type: input.document_type, password: customerProvidedPassword, });
    
    Blurry or low-resolution scans — Lekha's vision model returns a confidence field per field. Build a threshold: if any key field (name, PAN number) has confidence below 0.8, route to human review automatically.
    Missing documents — If the customer didn't upload a salary slip, tell the agent to skip income verification and flag it as income_verified: null (unverified, not failed). The underwriting team can then request the missing doc.
    Multi-page statements — Lekha handles multi-page PDFs natively. A 12-month bank statement is processed as a single document, returning aggregated transaction data and monthly summaries.
    

    Wiring It to an API Route

    typescript // src/app/api/kyc/route.ts import { NextRequest, NextResponse } from "next/server"; import { runKycAgent } from "@/lib/kyc-agent";

    export async function POST(req: NextRequest) { const body = await req.json();

    const verdict = await runKycAgent({ pan_card_url: body.pan_card_url, bank_statement_url: body.bank_statement_url, salary_slip_url: body.salary_slip_url, declared_name: body.name, declared_annual_income: body.annual_income, declared_address: body.address, });

    return NextResponse.json({ success: true, data: verdict }); } ```

    A full KYC run — three documents extracted and compared — completes in 6–10 seconds. That's 100–200x faster than a human agent.

    What Lekha Extracts Per Document Type

    | Document | Key fields extracted | | -------------- | -------------------------------------------------------------------------------------- | | PAN card | name, pan_number, date_of_birth, father_name | | Bank statement | account_holder, account_number, bank, transactions[], monthly_summary, average_balance | | Salary slip | employee_name, employer, month, gross_salary, net_salary, deductions | | ITR | assessee_name, pan, assessment_year, gross_total_income, tax_paid | | Form 16 | employee_name, employer_name, pan, gross_salary, tds_deducted |

    You can test extractions on any document type at the Lekha Playground before writing a single line of code.

    FAQ

    Does Lekha store the documents I send? No. Lekha processes documents in memory only and does not write them to disk or any storage layer. This is required for DPDP compliance. See the DPDP compliance guide for architecture details. Which banks' statements does Lekha support? Lekha supports all major Indian banks: HDFC, ICICI, SBI, Axis, Kotak, Yes Bank, IndusInd, IDFC First, AU Small Finance, and 20+ others. The same API call works regardless of bank — no format-specific code needed. Can I use this for Aadhaar-based KYC? Lekha extracts data from Aadhaar PDFs (name, date of birth, gender, address, masked Aadhaar number). For full Aadhaar e-KYC (OTP-based verification), you need an AUA/KUA licence from UIDAI — Lekha handles the document parsing step only. What if the customer uploads a photo of a document instead of a PDF? Lekha accepts images (JPEG, PNG, WEBP) and PDFs. Mobile captures of documents are a common case — the vision model handles rotation, glare, and perspective distortion that breaks traditional OCR.

    Ready to automate your KYC pipeline? Get a free Lekha API key and run your first extraction in under 5 minutes. The API is free for the first 100 documents — no credit card required. Browse the full document schema reference in the Lekha docs.