How to Build a claims processing Agent Using LangGraph in TypeScript for payments

By Cyprian AaronsUpdated 2026-04-21
claims-processinglanggraphtypescriptpayments

A claims processing agent for payments takes an incoming claim, validates the request, checks policy and transaction context, routes exceptions, and either approves, rejects, or escalates it with a full audit trail. For payment teams, this matters because claims are where money moves under pressure: chargebacks, disputes, refunds, failed transfers, and reimbursement requests all need deterministic handling, traceability, and tight controls.

Architecture

A production claims agent for payments usually needs these components:

  • Ingress layer

    • Accepts claim payloads from API, queue, or webhook sources.
    • Normalizes fields like claimId, paymentId, customerId, amount, reasonCode, and jurisdiction.
  • Validation node

    • Checks required fields, schema correctness, idempotency keys, and payment-specific constraints.
    • Rejects malformed claims before any downstream lookup.
  • Context retrieval node

    • Pulls transaction history, account status, dispute history, KYC flags, and policy rules.
    • Keeps the agent grounded in internal systems instead of guessing.
  • Decision node

    • Applies deterministic rules first.
    • Uses an LLM only for classification or summarization where human language is involved.
  • Escalation / human review node

    • Routes edge cases to ops or compliance when confidence is low or policy conflicts exist.
    • Produces a concise review packet.
  • Audit and persistence layer

    • Writes every state transition, tool call, and final decision.
    • Stores immutable evidence for compliance and post-incident review.

Implementation

1) Define the graph state and typed outputs

For payments work, keep the state explicit. You want every node to read and write predictable fields so you can audit decisions later.

import { Annotation } from "@langchain/langgraph";

export type Claim = {
  claimId: string;
  paymentId: string;
  customerId: string;
  amount: number;
  currency: string;
  reasonCode: string;
  jurisdiction: string;
};

export type ClaimDecision = {
  status: "approved" | "rejected" | "needs_review";
  reason: string;
};

export const ClaimState = Annotation.Root({
  claim: Annotation<Claim>(),
  validationErrors: Annotation<string[]>({
    default: () => [],
    reducer: (a, b) => [...a, ...b],
  }),
  transactionContext: Annotation<any>(),
  decision: Annotation<ClaimDecision | null>({
    default: () => null,
    reducer: (_, next) => next,
  }),
});

2) Build nodes with deterministic checks first

Use normal TypeScript functions as graph nodes. Keep validation strict; payment claims should fail closed if required data is missing or inconsistent.

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

const validateClaim = async (state: typeof ClaimState.State) => {
  const errors: string[] = [];
  const c = state.claim;

  if (!c.claimId) errors.push("missing_claim_id");
  if (!c.paymentId) errors.push("missing_payment_id");
  if (!c.customerId) errors.push("missing_customer_id");
  if (c.amount <= 0) errors.push("invalid_amount");
  if (!["USD", "EUR", "GBP"].includes(c.currency)) errors.push("unsupported_currency");

  return { validationErrors: errors };
};

const fetchTransactionContext = async (state: typeof ClaimState.State) => {
  // Replace with real DB/API calls
  const context = {
    paymentStatus: "settled",
    disputedBefore: false,
    kycStatus: "verified",
    riskFlag: false,
    policyEligible: true,
    residencyRegion: "eu-west-1",
  };

  return { transactionContext: context };
};

const decideClaim = async (state: typeof ClaimState.State) => {
  const { validationErrors, transactionContext } = state;

  if (validationErrors.length > 0) {
    return {
      decision: {
        status: "rejected",
        reason: `validation_failed:${validationErrors.join(",")}`,
      },
    };
    }

  if (!transactionContext.policyEligible || transactionContext.riskFlag) {
    return {
      decision: {
        status: "needs_review",
        reason: "policy_or_risk_exception",
      },
    };
  }

  return {
    decision: {
      status: "approved",
      reason: "eligible_and_verified",
    },
  };
};

3) Wire routing with StateGraph and conditional edges

This is the core pattern. Validate first, enrich second, decide third. If validation fails or risk is high, route to review instead of trying to be clever.

const routeAfterDecision = (state: typeof ClaimState.State) => {
if (!state.decision) return END;
return state.decision.status === "needs_review" ? "review" : END;
};

const reviewClaim = async (state: typeof ClaimState.State) => {
return {
decision:
state.decision ?? {
status:"needs_review",
reason:"manual_review_required",
},
};
};

const graph = new StateGraph(ClaimState)
.addNode("validate", validateClaim)
.addNode("context", fetchTransactionContext)
.addNode("decide", decideClaim)
.addNode("review", reviewClaim)
.addEdge(START,"validate")
.addEdge("validate","context")
.addEdge("context","decide")
.addConditionalEdges("decide", routeAfterDecision)
.build();

const result = await graph.invoke({
claim:{
claimId:"clm_123",
paymentId:"pay_456",
customerId:"cus_789",
amount:120.5,
currency:"USD",
reasonCode:"duplicate_charge",
jurisdiction:"EU",
},
});

console.log(result.decision);

###4) Add an LLM only where it helps

For claims processing in payments, use the model for classification or summarization of free-text dispute notes. Keep approval logic outside the model so you can explain every outcome to compliance and auditors.

A common pattern is to add a separate node that classifies reason text into a normalized category using a structured output parser. Then feed that category into your deterministic decision node.

Production Considerations

  • Deployment

    • Run the graph behind an API with idempotency keys per claim.
    • Persist graph state in your own store so retries do not duplicate payouts or reversals.
    • Keep region-specific deployments aligned with data residency requirements; EU claims should stay in EU infrastructure when policy demands it.
  • Monitoring

    • Track approval rate, manual-review rate, rejection reasons, latency per node, and tool failure counts.
    • Emit structured logs with claimId, paymentId, decision.status, and correlationId.
  • Guardrails

  • Enforce hard validation before any external lookup that could leak PII unnecessarily.

  • Redact account numbers, card data, and sensitive dispute text before sending anything to an LLM.

  • Store immutable audit events for every state transition and every human override.

  • Add policy checks for AML/KYC flags so the agent never auto-approves restricted cases.

Common Pitfalls

  1. Letting the model make the final payout decision

    • Avoid this by making the LLM classify or summarize only.
    • Final approve/reject logic should be deterministic TypeScript code backed by policy rules.
  2. Skipping idempotency

    • Claims systems get retried constantly from queues and webhooks.
    • Use a unique claim key plus a dedupe store so one claim cannot trigger multiple reimbursements.
  3. Ignoring residency and audit requirements

    • Payment claims often carry PII and regulated transaction data.
    • Pin storage and execution regions correctly, log every transition immutably, and make sure reviewers can reconstruct why a claim was approved or escalated.

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