Small e-commerce businesses lose thousands in chargeback disputes due to slow, manual evidence gathering and missed deadlines, leading to lost revenue and increased fees.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This recipe builds an automated chargeback dispute response system for Stripe-powered e-commerce businesses. When Stripe fires a charge.dispute.created webhook, a coordinated team of specialized agents — using the Databricks Agent Mesh — collects transaction evidence from the Stripe API, generates a legal rebuttal letter via Databricks-hosted LLMs, and uses a confidence router to decide whether to auto-file the response or escalate to a human reviewer. A session service backed by Upstash Redis preserves multi-step workflow state, and a dashboard lists every dispute with manual override controls.
Prerequisites
Node.js 22+ and pnpm 10+
A Databricks workspace with a serving endpoint for a text-generation model (e.g., Databricks DBRX, Mixtral, or Llama-based)
A Stripe account with a secret key and webhook signing secret
An Upstash Redis database (the free tier works fine)
Familiarity with Next.js App Router and TypeScript
Step 1: Create the project and install dependencies
Start a new Next.js project or use the scaffolded shell. The package.json below pins every dependency to an exact version — no ^ ranges.
Expected output: pnpm resolves all dependencies and writes a pnpm-lock.yaml. No errors in the install log.
Step 2: Configure environment variables
Create .env.example with the placeholders for every value the system reads at runtime:
env
# Env vars used by databricks-agent-mesh-for-stripe-dispute-response-automation.# Keep placeholders only — never commit real values.NODE_ENV=developmentSTRIPE_SECRET_KEY=<your-stripe-secret-key>STRIPE_WEBHOOK_SECRET=<your-stripe-webhook-secret>UPSTASH_REDIS_REST_URL=<your-upstash-redis-rest-url>UPSTASH_REDIS_REST_TOKEN=<your-upstash-redis-rest-token>DATABRICKS_API_KEY=<your-databricks-api-key>DATABRICKS_BASE_URL=<your-databricks-workspace-url>DATABRICKS_MODEL=<your-databricks-model-endpoint-name>HANDOFF_QUEUE_URL=http://localhost:9090/handoffNEXT_PUBLIC_APP_URL=http://localhost:3000
Copy it to .env.local and fill in your real credentials:
terminal
cp .env.example .env.local
Expected output: The file .env.local now exists. Edit it with your Stripe secret key, webhook secret, Upstash Redis URL and token, and Databricks workspace URL, API key, and model endpoint name.
Step 3: Define shared types
Create src/lib/types.ts. This file re-exports types from the Agent Mesh packages and defines the domain objects — DisputeRecord, EvidenceBundle, and RebuttalResult — that every other module depends on.
ts
import { IncomingRequestSchema, type IncomingRequest, AgentResponseSchema, type AgentResponse, type ContextPacket, type SessionRecord, type TurnEntry, type AgentConfig, type ConfidenceDecision,} from "@reaatech/agent-mesh";import { type RoutingDecision, type HandoffPayload, type HandoffResult } from "@reaatech/agent-handoff";import { createSession } from "@reaatech/agent-mesh-session";export type DisputeStatus = "pending" | "evidence_collecting" | "rebuttal_generating" | "auto_filed" | "escalated" | "resolved" | "failed";export interface DisputeRecord { id: string; stripeDisputeId: string; chargeId: string; amount: number; currency: string; reason: string; status: DisputeStatus; evidence: EvidenceBundle | null; rebuttal: RebuttalResult | null; confidenceScore: number; routingDecision: string; createdAt: string; updatedAt: string;}export interface EvidenceBundle { disputeId: string; chargeId: string; transactionReceipt: string | null; customerCommunication: string | null; shippingProof: string | null; serviceProof: string | null; metadata: Record<string, unknown>;}export interface RebuttalResult { rebuttalText: string; supportingEvidence: Array<string>; generatedAt: string;}createSession({ userId: "__import_gate__", employeeId: "__import_gate__", activeAgent: "__import_gate__",}).catch(() => {});export type { RoutingDecision, HandoffPayload, HandoffResult, ContextPacket, SessionRecord, TurnEntry, AgentConfig, AgentResponse, IncomingRequest, IncomingRequestSchema, AgentResponseSchema, ConfidenceDecision,};
Expected output: TypeScript type-checks pass (pnpm typecheck exits 0). The interfaces and re-exported types are consumed by every service below.
Step 4: Create the Databricks LLM client
The src/lib/llm.ts module wraps Databricks-hosted models through the Vercel AI SDK. It reads credentials from environment variables and exposes two functions: one for freeform rebuttal text generation and one for structured JSON output.
ts
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";import { generateText, Output } from "ai";import { z } from "zod";export class LlmError extends Error { readonly code: string; constructor(message: string, code: string = "LLM_ERROR") { super(message); this.code = code; this.name = "LlmError"; }}function getProvider() { const baseUrl = process.env.DATABRICKS_BASE_URL; const apiKey = process.env.DATABRICKS_API_KEY; if (!baseUrl) throw new LlmError("DATABRICKS_BASE_URL is not set", "CONFIG_ERROR"); if (!apiKey) throw new LlmError("DATABRICKS_API_KEY is not set", "CONFIG_ERROR"); return createOpenAICompatible({ baseURL: baseUrl, name: "databricks", apiKey, });}function getModel() { const model = process.env.DATABRICKS_MODEL; if (!model) throw new LlmError("DATABRICKS_MODEL is not set", "CONFIG_ERROR"); return getProvider().chatModel(model);}export async function generateRebuttalText( prompt: string, system: string,): Promise<string> { try { const result = await generateText({ model: getModel(), system, prompt, }); return result.text; } catch (err) { throw new LlmError( `Failed to generate rebuttal text: ${(err as Error).message}`, "REBUTTAL_GENERATION_ERROR", ); }}export async function generateStructured<T>( prompt: string, schema: z.ZodType<T>,): Promise<T> { try { const result = await generateText({ model: getModel(), prompt, output: Output.object({ schema }), }); return result.output; } catch (err) { throw new LlmError( `Failed to generate structured output: ${(err as Error).message}`, "STRUCTURED_GENERATION_ERROR", ); }}
Expected output: The Databricks-compatible provider is configured. generateRebuttalText calls the model and returns plain text; generateStructured returns typed JSON by passing a Zod schema to Output.object.
Step 5: Build the evidence collector
The evidence collector calls the Stripe API to retrieve a dispute, its charge, and the payment intent chain, then assembles an EvidenceBundle.
ts
import Stripe from "stripe";import { logger } from "@reaatech/agent-mesh-observability";import type { EvidenceBundle } from "../lib/types.js";let _stripe: Stripe | null = null;function getStripe(): Stripe { if (!_stripe) { const secretKey = process.env.STRIPE_SECRET_KEY; if (!secretKey) throw new Error("STRIPE_SECRET_KEY is not set"); _stripe = new Stripe(secretKey); } return _stripe;
Expected output:collectEvidence("dp_123") returns a bundle with the Stripe dispute, charge, and payment intent data. formatEvidenceForRebuttal produces a human-readable summary for the LLM prompt.
Step 6: Create the rebuttal generator
The rebuttal generator feeds the evidence bundle into the Databricks-hosted LLM and asks it to write a formal chargeback rebuttal letter.
ts
import { logger } from "@reaatech/agent-mesh-observability";import { generateRebuttalText } from "../lib/llm.js";import type { EvidenceBundle, RebuttalResult, DisputeRecord } from "../lib/types.js";export class RebuttalGenerationError extends Error { readonly code: string = "REBUTTAL_GENERATION_ERROR"; readonly cause: unknown; constructor(message: string, cause: unknown) { super(message); this.name = "RebuttalGenerationError"; this.cause = cause; }}export async function generateRebuttal( evidence: EvidenceBundle, dispute: DisputeRecord,): Promise<RebuttalResult> { const system = "You are a chargeback rebuttal specialist. Write a formal rebuttal letter citing the following evidence items."; const formatted = formatEvidenceForDispute(evidence, dispute); const start = Date.now(); try { const rebuttalText = await generateRebuttalText(formatted, system); const result: RebuttalResult = { rebuttalText: rebuttalText, supportingEvidence: extractEvidenceCitations(rebuttalText), generatedAt: new Date().toISOString(), }; logger.info("Rebuttal generated", { disputeId: dispute.id, durationMs: Date.now() - start, evidenceCount: result.supportingEvidence.length, }); return result; } catch (err) { logger.error("Rebuttal generation failed", { disputeId: dispute.id, error: (err as Error).message, durationMs: Date.now() - start, }); throw new RebuttalGenerationError( `Failed to generate rebuttal for dispute ${dispute.id}: ${(err as Error).message}`, err, ); }}function formatEvidenceForDispute( evidence: EvidenceBundle, dispute: DisputeRecord,): string { return [ `Dispute ID: ${dispute.id}`, `Stripe Dispute ID: ${dispute.stripeDisputeId}`, `Charge ID: ${dispute.chargeId}`, `Amount: ${String(dispute.amount)} ${dispute.currency}`, `Reason: ${dispute.reason}`, `Status: ${dispute.status}`, "", "Evidence:", ` Transaction Receipt: ${evidence.transactionReceipt ?? "N/A"}`, ` Customer Communication: ${evidence.customerCommunication ?? "N/A"}`, ` Shipping Proof: ${evidence.shippingProof ?? "N/A"}`, ` Service Proof: ${evidence.serviceProof ?? "N/A"}`, ].join("\n");}function extractEvidenceCitations(text: string): string[] { const citations: string[] = []; const lines = text.split("\n"); for (const line of lines) { if (/evidence|receipt|communication|proof|transaction/i.test(line)) { citations.push(line.trim()); } } return citations.length > 0 ? citations : [text.substring(0, 200)];}
Expected output:generateRebuttal(evidenceBundle, disputeRecord) calls the Databricks LLM and returns a RebuttalResult with the generated text and extracted citations.
Step 7: Implement the confidence service
The confidence router decides whether to auto-file or escalate. The ConfidenceRouter from @reaatech/confidence-router takes the classification predictions and returns a ROUTE or FALLBACK decision.
Expected output: A prediction with confidence >= 0.8 returns { type: "ROUTE", target: "auto-file" }. A prediction below 0.3 returns { type: "FALLBACK" }. Middle values also fall back (clarification is disabled).
Step 8: Build the session and handoff services
The session service manages workflow state in Upstash Redis. The handoff service creates a structured escalation payload and sends it to a configurable queue endpoint.
ts
// src/services/session-service.tsimport { Redis } from "@upstash/redis";import { createSession as createReaaSession } from "@reaatech/agent-mesh-session";import { recordSessionLookupDuration, logger } from "@reaatech/agent-mesh-observability";import type { SessionRecord, TurnEntry } from "../lib/types.js";let _redis: Redis | null = null;function getRedis(): Redis { if (!_redis) { const url = process.env.UPSTASH_REDIS_REST_URL; const token = process.env.UPSTASH_REDIS_REST_TOKEN; if (!
And the handoff service for human escalation:
ts
// src/services/handoff-service.tsimport { createHandoffConfig, withRetry, HandoffError, type HandoffPayload, type RoutingDecision, type HandoffResult, type Message,} from "@reaatech/agent-handoff";import type { DisputeRecord } from "../lib/types.js";const handoffConfig = createHandoffConfig({ routing: { minConfidenceThreshold: 0.3 },});export { createHandoffConfig, withRetry, HandoffError, handoffConfig };export type { HandoffPayload, RoutingDecision, HandoffResult };async function postToHandoffQueue(payload: HandoffPayload):
Expected output:createSession writes a hash to Upstash Redis with a 1-hour TTL. escalateToHumanReview builds a full handoff payload and posts it with exponential backoff retry.
Step 9: Wire up observability
The observability module re-exports utilities from @reaatech/agent-mesh-observability and adds domain-specific logging helpers.
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, } as NextConfig["experimental"],};export default nextConfig;
Expected output: When the Next.js server starts, register() runs in the Node.js runtime, initializing OpenTelemetry. Without experimental.instrumentationHook: true, this file would be dead code — the flag is mandatory.
Step 10: Build the dispute orchestrator
This is the central coordinator. It wires all the services together: it dispatches to the evidence-collector and rebuttal-generator agents via agent-mesh-router, calls the real Stripe API, generates the rebuttal, runs confidence routing, and either auto-files or escalates.
ts
// src/services/dispute-orchestrator.tsimport { dispatchToAgent, buildTurnEntry, shouldCloseSession, getUpdatedWorkflowState } from "@reaatech/agent-mesh-router";import { type AgentConfig, type AgentResponse, IncomingRequestSchema, AgentResponseSchema, type SessionRecord } from "@reaatech/agent-mesh";import { recordAgentDispatchDuration, recordAgentDispatchError } from "@reaatech/agent-mesh-observability";import { createSession, getSession, appendTurn, closeSession, updateWorkflowState } from "./session-service.js";import { collectEvidence } from "./evidence-collector.js";import { generateRebuttal } from "./rebuttal-generator.js";import { decideAction } from "./confidence-service.js";import { escalateToHumanReview } from "./handoff-service.js";import { createDisputeLogger }
Expected output: Calling processDispute with a valid Stripe dispute ID triggers the full pipeline: session creation, agent dispatch, evidence collection, rebuttal generation, confidence scoring, and either auto-file or escalation.
Step 11: Create the API routes
Three API routes bring the system online:
Stripe webhook — app/api/webhook/stripe/route.ts — ingests Stripe dispute events, validates the signature, and starts the dispute workflow.
ts
import type { NextRequest } from "next/server";import { NextResponse } from "next/server";import Stripe from "stripe";import { IncomingRequestSchema } from "@reaatech/agent-mesh";import { processDispute } from "../../../../src/services/dispute-orchestrator.js";function getStripe(): Stripe { const secretKey = process.env.STRIPE_SECRET_KEY; if (!secretKey) throw new Error("STRIPE_SECRET_KEY is not set"); return new Stripe(secretKey);}export async function POST(req: NextRequest): Promise<NextResponse> { const rawBody = await req.text(); const sig = req.headers.get("stripe-signature"); if (!sig) { return NextResponse.json({ error: "invalid signature" }, { status: 401 }); } let event: Stripe.Event; try { const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!webhookSecret) throw new Error("STRIPE_WEBHOOK_SECRET is not set"); event = getStripe().webhooks.constructEvent(rawBody, sig, webhookSecret); } catch { return NextResponse.json({ error: "invalid signature" }, { status: 401 }); } if (event.type !== "charge.dispute.created") { return NextResponse.json({ received: true }); } const body = JSON.parse(rawBody) as { data?: { object?: Record<string, unknown> } }; const disputeObject: Record<string, unknown> = body.data?.object ?? {}; let parsedData: unknown; try { parsedData = IncomingRequestSchema.parse({ input: JSON.stringify(disputeObject), employee_id: typeof disputeObject.id === "string" ? disputeObject.id : "", display_name: "charge.dispute.created", }); } catch { return NextResponse.json({ error: "invalid payload" }, { status: 400 }); } await processDispute(parsedData); return NextResponse.json({ received: true });}
Disputes list — app/api/disputes/route.ts — lists all active disputes from Redis.
ts
import type { NextRequest } from "next/server";import { NextResponse } from "next/server";import { Redis } from "@upstash/redis";import { getSession } from "../../../src/services/session-service.js";let _redis: Redis | null = null;function getRedis(): Redis { if (!_redis) { const url = process.env.UPSTASH_REDIS_REST_URL; const token = process.env.UPSTASH_REDIS_REST_TOKEN; if (!url) throw new Error("UPSTASH_REDIS_REST_URL is not set"); if (!token) throw new Error("UPSTASH_REDIS_REST_TOKEN is not set"); _redis = new Redis({ url, token }); } return _redis;}export async function GET(req: NextRequest): Promise<NextResponse> { try { const statusFilter = req.nextUrl.searchParams.get("status"); const redis = getRedis(); const keys = await redis.keys("session:*"); const disputes: Array<Record<string, unknown>> = []; for (const key of keys) { const sessionId = key.replace("session:", ""); const session = await getSession(sessionId); if (session && (!statusFilter || session.status === statusFilter)) { disputes.push({ id: sessionId, userId: session.user_id, status: session.status, activeAgent: session.active_agent, createdAt: session.created_at, }); } } return NextResponse.json({ disputes }); } catch { return NextResponse.json({ error: "Failed to fetch disputes" }, { status: 500 }); }}
Single dispute — app/api/disputes/[id]/route.ts — fetches one dispute and accepts control actions.
Expected output: Send a POST to /api/webhook/stripe with a valid Stripe-signed body and the server responds { "received": true }. GET /api/disputes returns an array. POST /api/disputes/{id} with { "action": "close" } marks the dispute completed.
Step 12: Build the dashboard and run tests
The dashboard is a server component that reads disputes from the API and renders them in a table with action buttons.
Expected output: TypeScript exits with zero errors. ESLint finds no issues. Vitest reports numFailedTests=0, numTotalTests>=3, and coverage >= 90% on lines, branches, functions, and statements for runtime code under src/ and app/**/route.ts.
Next steps
Add a human-review UI — build a dedicated review page that loads the handoff queue, displays the compressed context alongside the full evidence bundle, and lets the reviewer accept, modify, or reject the auto-generated rebuttal before filing.
Wire up real Databricks Agent Mesh — replace the local agent configurations with remote Databricks Agent endpoints registered in your workspace, and add telemetry export to Databricks Lakeview for cross-system observability.
Extend dispute reasons — train or prompt the confidence router on dispute sub-reasons (fraudulent, product_not_received, duplicate, etc.) to produce different auto-file strategies per category, such as requiring higher confidence thresholds for high-value disputes.
}
export class EvidenceCollectionError extends Error {