How to Build a KYC verification Agent Using LangGraph in TypeScript for fintech

By Cyprian AaronsUpdated 2026-04-21
kyc-verificationlanggraphtypescriptfintech

A KYC verification agent automates the intake, validation, and decisioning flow for customer identity checks. In fintech, that matters because onboarding delays kill conversion, while weak controls create compliance exposure, audit gaps, and bad customer risk decisions.

Architecture

A production KYC agent in LangGraph should be split into these components:

  • Input normalization node

    • Cleans up raw customer payloads from web forms, mobile apps, or ops tools.
    • Maps fields into a strict internal schema before any checks run.
  • Document extraction node

    • Pulls structured data from passport, driver’s license, utility bill, or company registration documents.
    • Calls OCR or document AI services and returns confidence scores.
  • Policy validation node

    • Checks required fields against jurisdiction-specific rules.
    • Enforces things like address freshness, document expiry, age thresholds, and country restrictions.
  • Risk scoring node

    • Combines signals such as document mismatch, PEP/sanctions hits, geolocation anomalies, and manual review flags.
    • Produces a deterministic decision: approve, reject, or manual_review.
  • Audit and evidence node

    • Persists every intermediate state with timestamps.
    • Stores reasons for each decision so compliance teams can reconstruct the flow later.
  • Human review handoff

    • Routes ambiguous cases to an analyst queue.
    • Lets an operator override the agent with a signed-off decision.

Implementation

1) Define the graph state and typed outputs

For fintech work, don’t pass around loose JSON blobs. Use a typed state object so every node has a contract and your audit trail stays consistent.

import { Annotation, StateGraph, START, END } from "@langchain/langgraph";

type KycDecision = "approve" | "reject" | "manual_review";

type KycState = {
  customerId: string;
  fullName: string;
  dateOfBirth?: string;
  country: string;
  documentType?: "passport" | "id_card" | "driver_license";
  documentNumber?: string;
  extracted?: {
    name?: string;
    dob?: string;
    expiryDate?: string;
    confidence: number;
  };
  sanctionsHit?: boolean;
  pepHit?: boolean;
  riskScore?: number;
  decision?: KycDecision;
  reasons: string[];
};

const KycAnnotation = Annotation.Root({
  customerId: Annotation<string>(),
  fullName: Annotation<string>(),
  country: Annotation<string>(),
  reasons: Annotation<string[]>({
    reducer: (left, right) => [...left, ...right],
    default: () => [],
  }),
});

2) Build deterministic nodes for validation and scoring

Keep policy logic explicit. In regulated flows, you want predictable behavior that compliance can review line by line.

const normalizeNode = async (state: KycState): Promise<Partial<KycState>> => {
  return {
    fullName: state.fullName.trim().replace(/\s+/g, " "),
    country: state.country.toUpperCase(),
    reasons: ["normalized input"],
  };
};

const validateNode = async (state: KycState): Promise<Partial<KycState>> => {
  const reasons: string[] = [];

  if (!state.documentType) reasons.push("missing document type");
  if (!state.documentNumber) reasons.push("missing document number");
  if (!state.dateOfBirth) reasons.push("missing date of birth");

  return {
    reasons,
    decision: reasons.length > 0 ? "manual_review" : undefined,
  };
};

const scoreNode = async (state: KycState): Promise<Partial<KycState>> => {
  let score = 0;

  if (state.sanctionsHit) score += 70;
   if (state.pepHit) score += 30;
   if (state.extracted?.confidence && state.extracted.confidence < 0.85) score += 20;

   const decision =
     score >= 70 ? "reject" :
     score >= 20 ? "manual_review" :
     "approve";

   return {
     riskScore: score,
     decision,
     reasons: [`risk scored at ${score}`],
   };
};

3) Route to human review when policy is unclear

Use LangGraph’s conditional edges so the graph can stop automated processing and hand off risky cases.

const routeAfterValidation = (state: KycState) => {
   if (state.decision === "manual_review") return "review";
   return "score";
};

const reviewNode = async (state: KycState): Promise<Partial<KycState>> => {
   // Replace this with your analyst queue integration.
   return {
     reasons: ["routed to human reviewer"],
   };
};

4) Compile the graph and invoke it with real input

This is the actual execution pattern. The graph is small enough to reason about but structured enough for production control.

const graph = new StateGraph(KycAnnotation)
   .addNode("normalize", normalizeNode)
   .addNode("validate", validateNode)
   .addNode("score", scoreNode)
   .addNode("review", reviewNode)
   .addEdge(START, "normalize")
   .addEdge("normalize", "validate")
   .addConditionalEdges("validate", routeAfterValidation, {
      review: "review",
      score: "score",
   })
   .addEdge("score", END)
   .addEdge("review", END);

const app = graph.compile();

const result = await app.invoke({
   customerId: "cus_123",
   fullName: " Jane Doe ",
   country: "gb",
   documentType: "passport",
   documentNumber: "P1234567",
   dateOfBirth: "1992-04-18",
   sanctionsHit: false,
   pepHit: false,
});

console.log(result.decision);
console.log(result.reasons);

Production Considerations

  • Auditability
    • Persist every graph input/output pair with immutable timestamps.
  • Data residency
    • Keep PII inside approved regions; don’t send identity docs to out-of-region model endpoints.
  • Monitoring
    • Track approval rate, manual-review rate, sanctions hit rate, false reject rate, and node latency.
  • Guardrails
    • Never let an LLM make final compliance decisions without deterministic policy checks and human override paths.

Common Pitfalls

  • Using the LLM as the final decider

    Don’t ask a model to “approve this customer” directly. Use it for extraction or summarization only; keep policy enforcement in code.

  • Skipping schema enforcement

    Loose objects break under real onboarding traffic. Define typed state and validate required fields before scoring.

  • Ignoring jurisdiction-specific rules

    A UK retail bank and a Singapore payments firm do not share the same onboarding requirements. Parameterize rules by country and product line instead of hardcoding one global policy.


Keep learning

By Cyprian Aarons, AI Consultant at Topiax.

Want the complete 8-step roadmap?

Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.

Get the Starter Kit

Related Guides