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

By Cyprian AaronsUpdated 2026-04-21
kyc-verificationlanggraphtypescriptpayments

A KYC verification agent for payments takes a customer’s identity data, validates it against policy, pulls in external checks, and decides whether to approve, reject, or escalate for manual review. For payment flows, this matters because you need fast onboarding without weakening compliance, auditability, or fraud controls.

Architecture

A production KYC agent for payments usually needs these components:

  • Input normalization

    • Accepts customer name, date of birth, address, government ID number, and country.
    • Normalizes formats before any verification call.
  • Policy engine

    • Encodes KYC rules by jurisdiction and risk tier.
    • Decides when to block, when to request more data, and when to escalate.
  • Verification tools

    • Calls external services for document validation, sanctions screening, PEP checks, and address verification.
    • Keeps vendor calls isolated behind typed functions.
  • LangGraph workflow

    • Orchestrates the steps with conditional routing.
    • Keeps the process deterministic enough for compliance review.
  • Audit trail store

    • Persists every decision, tool response, and final outcome.
    • Required for disputes, regulator requests, and internal reviews.
  • Manual review queue

    • Handles ambiguous cases.
    • Lets ops staff resolve edge cases without blocking the whole payment flow.

Implementation

1) Define the state and tool functions

Use a typed state object so every node in the graph knows what it can read and write. For payments, keep PII explicit in the state shape so you can control logging and redaction later.

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

const KycInputSchema = z.object({
  fullName: z.string(),
  dateOfBirth: z.string(),
  country: z.string(),
  idNumber: z.string(),
  address: z.string().optional(),
});

type KycInput = z.infer<typeof KycInputSchema>;

const KycState = Annotation.Root({
  input: Annotation<KycInput>(),
  normalized: Annotation<Partial<KycInput>>(),
  sanctionsHit: Annotation<boolean>(),
  documentVerified: Annotation<boolean>(),
  riskScore: Annotation<number>(),
  decision: Annotation<"approve" | "reject" | "manual_review">(),
  reasons: Annotation<string[]>({ default: () => [] }),
});

async function checkSanctions(input: Partial<KycInput>): Promise<boolean> {
  return input.fullName?.toLowerCase().includes("test") ?? false;
}

async function verifyDocument(input: Partial<KycInput>): Promise<boolean> {
  return Boolean(input.idNumber && input.country);
}

2) Build nodes that do one job each

Keep each node small. In payments systems that makes failures easier to isolate and retry without replaying the whole onboarding flow.

const normalizeNode = async (state: typeof KycState.State) => {
  const input = state.input;
  return {
    normalized: {
      fullName: input.fullName.trim(),
      dateOfBirth: input.dateOfBirth,
      country: input.country.toUpperCase(),
      idNumber: input.idNumber.replace(/\s+/g, ""),
      address: input.address?.trim(),
    },
    reasons: [...state.reasons],
    riskScore: state.riskScore ?? 0,
    sanctionsHit: false,
    documentVerified: false,
    decision: "manual_review" as const,
  };
};

const sanctionsNode = async (state: typeof KycState.State) => {
  const hit = await checkSanctions(state.normalized);
  return {
    sanctionsHit: hit,
    reasons: [...state.reasons, hit ? "Sanctions match detected" : "No sanctions match"],
    riskScore: (state.riskScore ?? 0) + (hit ? 80 : 5),
  };
};

const documentNode = async (state: typeof KycState.State) => {
  const verified = await verifyDocument(state.normalized);
  return {
    documentVerified: verified,
    reasons: [...state.reasons, verified ? "Identity document verified" : "Document verification failed"],
    riskScore: (state.riskScore ?? 0) + (verified ? -10 : 40),
  };
};

3) Add routing logic for approve/reject/manual review

This is where LangGraph earns its keep. You define one decision function and route based on objective thresholds instead of scattering if-statements across handlers.

const decideNode = async (state: typeof KycState.State) => {
	const score = state.riskScore ?? 0;

	if (state.sanctionsHit) {
		return {
			decision: "reject" as const,
			reasons: [...state.reasons, "Rejected due to sanctions hit"],
		};
	}

	if (!state.documentVerified || score >= 50) {
		return {
			decision: "manual_review" as const,
			reasons: [...state.reasons, "Escalated to manual review"],
		};
	}

	return {
		decision: "approve" as const,
		reasons: [...state.reasons, "KYC passed"],
	};
};

const graph = new StateGraph(KycState)
	.addNode("normalize", normalizeNode)
	.addNode("sanctions", sanctionsNode)
	.addNode("document", documentNode)
	.addNode("decide", decideNode)
	.addEdge(START, "normalize")
	.addEdge("normalize", "sanctions")
	.addEdge("sanctions", "document")
	.addEdge("document", "decide")
	.addEdge("decide", END);

export const kycApp = graph.compile();

4) Invoke the agent from your payment onboarding service

Treat the graph as a pure workflow engine. Your API layer should validate input first, then pass a sanitized object into the graph and persist the result with an audit record.

async function runKyc(inputPayload: unknown) {
	const parsed = KycInputSchema.parse(inputPayload);

	const result = await kycApp.invoke({
		input: parsed,
		riskScore: 0,
		sanctionsHit: false,
		documentVerified:false,
		reasons:[]
	});

	return {
		status:
			result.decision === "approve"
				? "approved"
				? result.decision === "reject"
				? "rejected"
				:"needs_review",
				reviewReason?: result.reasons
				riskScore? result.riskScore
				auditTrail?: result.reasons
				documentVerified?: result.documentVerified
				sanctionsHit?: result.sanctionsHit
			reviewReason:
			result.decision === "approve" ? undefined : result.reasons.at(-1),
	  };
}

Production Considerations

  • Persist every run

    • Store input hash, policy version, tool outputs, decision path, and final outcome.
    • Regulators will ask why a customer was approved or rejected months later.
  • Control data residency

    • Keep PII in-region if your payment program operates across multiple jurisdictions.
    • Make sure sanction screening vendors and model providers do not move data outside approved regions.
  • Add retry boundaries around vendor calls

    • Sanctions APIs fail. Document verification APIs time out.
    • Retry only idempotent calls and never duplicate side effects like case creation or webhook dispatch.
  • Monitor decision drift

    • Track approval rate by country, doc type, and risk tier.
    • A sudden drop usually means a vendor issue or a policy regression.

Common Pitfalls

  1. Mixing policy with orchestration

    • Don’t bury compliance rules inside API handlers.
    • Keep them in graph nodes or dedicated policy functions so you can version them independently.
  2. Logging raw PII everywhere

    • Payment teams do this constantly during debugging.
    • Redact names, ID numbers, addresses, and dates of birth in logs; keep full values only in encrypted stores with strict access controls.
  3. Letting every failure become a reject

    • A timeout from a third-party verifier is not the same as a failed identity check.
    • Route uncertain states to manual review so you don’t create false negatives that block legitimate payments.

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