DevStacked
PaymentsAugust 22, 202621 min read

Stripe Refunds & Disputes in Next.js 16 (2026)

Sooner or later, every Stripe integration hits the same wall: a customer wants their money back, or worse, their bank files a chargeback before they even email you. Most Stripe tutorials stop at "payment succeeded" and leave you to figure out refunds and disputes on your own — usually while a real customer is waiting.

This guide fills that gap. You'll learn how refunds and disputes actually differ under the hood, how to build a working refund flow with Server Actions in Next.js 16, how to listen for the webhook events that keep your database in sync, and how to submit dispute evidence through the API instead of digging through the Stripe Dashboard every time. Every code snippet here is checked against Stripe's current API reference and Node SDK, so you can copy it straight into a real project.


Refunds vs. Disputes: Not the Same Problem

These two get lumped together constantly, but they're triggered by completely different people and solved with completely different code.

  • A refund is something you initiate. A customer emails you, you agree, you send their money back. You're in control of the timing and the amount.
  • A dispute (also called a chargeback) is something the customer's bank initiates. They contact their card issuer directly, skipping you entirely. The disputed amount plus a dispute fee gets pulled from your Stripe balance immediately, and you only find out because Stripe sends you a webhook.

That difference — "I chose to do this" versus "this happened to me" — is why they need separate code paths, separate webhook listeners, and separate UI. Refunds are proactive; disputes are reactive, with a clock already running before you've read the notification.

💡 Tip: A well-handled refund can sometimes prevent a dispute entirely — if you refund a suspicious or unhappy customer fast enough, their bank never gets involved. That's the main reason to treat refunds as a first-class feature instead of a Dashboard afterthought.


Prerequisites

This guide uses:

  • Next.js 16 with the App Router
  • TypeScript in strict mode
  • Stripe Node SDK (stripe package, latest version)
  • A Stripe account in test mode
  • Basic familiarity with Stripe Payment Intents (see our Stripe Payment Element guide if you haven't built a checkout flow yet)

Step 1: Set Up the Stripe Server Client

If you've already got a Stripe integration, you likely have this file already — reuse it instead of creating a second Stripe instance.

// lib/stripe/server.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2026-07-29.dahlia",
});

⚠️ Common Mistake: Never import this file into a Client Component. STRIPE_SECRET_KEY must stay server-only — it can create refunds and pull money out of your account, so treat it with the same care as a database password.


Part 1: Refunds

Step 2: Understand What You Can Actually Refund

Before writing code, know the rules Stripe enforces on every refund request:

  • You refund against a Payment Intent or a Charge — not an order ID from your own database.
  • You can refund the full amount or a partial amount, and you can issue multiple partial refunds until the charge is fully refunded.
  • Once a charge is fully refunded, trying to refund it again throws an error.
  • You can't refund more than what's left unrefunded on the charge.
  • Refunds go back to the original payment method — you can't redirect a refund to a different card or bank account.

⚠️ Common Mistake: Trying to refund based on your own internal order status without checking the actual remaining refundable amount on the Stripe side first. If a customer support agent issued a partial refund yesterday and someone tries to refund the full original amount today, Stripe will reject it — always let the error message guide the UI, don't assume the full amount is always available.

Step 3: Create a Refund Server Action

This is the core building block — a Server Action that creates a refund against a Payment Intent, with an idempotency key so a network retry never creates two refunds for the same request.

// actions/create-refund.ts
"use server";

import { randomUUID } from "crypto";
import { stripe } from "@/lib/stripe/server";
import type Stripe from "stripe";

export type RefundState = {
  success?: boolean;
  error?: string;
  refundId?: string;
  status?: string;
};

export async function createRefund(
  _prev: RefundState,
  formData: FormData
): Promise<RefundState> {
  const paymentIntentId = formData.get("paymentIntentId") as string;
  const amountInput = formData.get("amount") as string; // dollars, from the form
  const reason = formData.get("reason") as string;

  if (!paymentIntentId) {
    return { error: "Payment Intent ID is required" };
  }

  try {
    const params: Stripe.RefundCreateParams = {
      payment_intent: paymentIntentId,
    };

    // Only set `amount` for a partial refund — omitting it refunds the full remaining balance
    if (amountInput) {
      params.amount = Math.round(Number(amountInput) * 100); // Stripe expects cents
    }

    if (reason === "duplicate" || reason === "fraudulent" || reason === "requested_by_customer") {
      params.reason = reason;
    }

    // Generated once per form submission — reused automatically if this exact
    // request is retried, so a flaky network never creates a duplicate refund
    const idempotencyKey = randomUUID();

    const refund = await stripe.refunds.create(params, { idempotencyKey });

    return { success: true, refundId: refund.id, status: refund.status ?? undefined };
  } catch (err) {
    return {
      error: err instanceof Error ? err.message : "Something went wrong while processing the refund.",
    };
  }
}

What's happening here:

  • payment_intent is the only required field — Stripe finds the underlying charge for you, so you never need to look up a separate charge ID.
  • amount is optional. Leave it out entirely for a full refund; set it for a partial one. Stripe amounts are always in the smallest currency unit (cents for USD), so a $12.50 refund is 1250, not 12.5.
  • reason is optional and only accepts three values: duplicate, fraudulent, or requested_by_customer. It's mostly for your own reporting and Stripe's Dashboard filtering — it doesn't change how the refund is processed.
  • The idempotencyKey argument is the second parameter to stripe.refunds.create(), not part of the request body. Generate it once per logical refund attempt (once per form submit), not once per retry — that's what makes it protect against duplicates.

⚠️ Common Mistake: Generating a new randomUUID() inside a retry loop instead of once per user action. If the key changes on every retry, Stripe treats each attempt as a brand-new refund request — which defeats the entire point of passing one.

Step 4: Build a Simple Refund Form

Here's a minimal admin-facing form using useActionState, the same pattern used throughout this site's Stripe guides.

// components/refund-form.tsx
"use client";

import { useActionState } from "react";
import { createRefund, type RefundState } from "@/actions/create-refund";

const initialState: RefundState = {};

export function RefundForm({ paymentIntentId }: { paymentIntentId: string }) {
  const [state, formAction, isPending] = useActionState(createRefund, initialState);

  return (
    <form action={formAction} className="flex flex-col gap-3 max-w-sm">
      <input type="hidden" name="paymentIntentId" value={paymentIntentId} />

      <label className="flex flex-col gap-1 text-sm">
        Amount to refund (leave blank for full refund)
        <input
          name="amount"
          type="number"
          step="0.01"
          min="0"
          placeholder="e.g. 12.50"
          className="rounded-lg border px-3 py-2"
        />
      </label>

      <label className="flex flex-col gap-1 text-sm">
        Reason
        <select name="reason" className="rounded-lg border px-3 py-2">
          <option value="requested_by_customer">Requested by customer</option>
          <option value="duplicate">Duplicate charge</option>
          <option value="fraudulent">Fraudulent</option>
        </select>
      </label>

      <button
        type="submit"
        disabled={isPending}
        className="rounded-lg bg-primary px-4 py-2 text-white disabled:opacity-50"
      >
        {isPending ? "Processing…" : "Issue Refund"}
      </button>

      {state.success && (
        <p className="text-sm text-green-600">
          Refund created ({state.refundId}) — status: {state.status}
        </p>
      )}
      {state.error && <p className="text-sm text-destructive">{state.error}</p>}
    </form>
  );
}

What's happening here: the paymentIntentId comes in as a prop (from wherever your order/payment lookup lives) and rides along as a hidden field, so the Server Action always knows exactly which payment it's refunding — the amount field is the only thing an admin actually types.

💡 Tip: Card refunds usually show a status of succeeded almost immediately, but some refund methods — bank transfers, certain wallets — settle asynchronously and start at pending. Don't assume succeeded the moment stripe.refunds.create() resolves; treat the webhook in the next step as the real source of truth.

Step 5: Understand pending_reason and failure_reason

A refund's status can land on pending or failed, and each has its own dedicated field explaining why — don't confuse the two, they're separate fields that only carry a value for their matching status.

pending_reason — only set when status: "pending":

pending_reasonMeaning
processingThe refund is being processed normally — just wait
insufficient_fundsYour Stripe balance can't currently cover the refund
charge_pendingThe original payment hasn't fully settled yet, so it can't be refunded yet

failure_reason — only set when status: "failed":

failure_reasonMeaning
charge_for_pending_refund_disputedThe customer disputed the charge while this refund was still pending — Stripe automatically fails the refund here to stop you from reimbursing the customer twice (once via the dispute, once via the refund)
insufficient_fundsThe refund stayed pending too long waiting on balance and ultimately failed
lost_or_stolen_card / expired_or_canceled_cardThe destination card can no longer accept the refund
declinedDeclined by Stripe's financial partners
merchant_requestThe refund was canceled at your request
unknownFailed for an unspecified reason
const refund = await stripe.refunds.create({ payment_intent: paymentIntentId });

if (refund.status === "pending" && refund.pending_reason === "insufficient_funds") {
  // Surface this to your finance/ops team — your Stripe balance needs topping up
}

if (refund.status === "failed" && refund.failure_reason === "charge_for_pending_refund_disputed") {
  // A dispute was filed while this refund was in flight — go handle the
  // dispute instead of retrying the refund, per Stripe's own guidance
}

💡 Tip: insufficient_funds is a valid value for both fields, but it means something different depending on which one you're reading — on pending_reason it means "still waiting," on failure_reason it means "gave up waiting and failed." Always check status first to know which field is actually meaningful.


Part 2: Webhooks for Refunds

Step 6: Listen for refund.created, refund.updated, and refund.failed

Your Server Action creating the refund only tells you the request was accepted — not that money actually landed back on the customer's card. Webhooks are what confirm the real outcome, the same way payment_intent.succeeded is the real source of truth for a payment, not the client-side redirect.

// app/api/webhook/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import Stripe from "stripe";
import { stripe } from "@/lib/stripe/server";

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const payload = await req.text(); // raw body — required for signature verification
  const signature = (await headers()).get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(payload, signature, endpointSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 });
  }

  switch (event.type) {
    case "refund.created": {
      const refund = event.data.object as Stripe.Refund;
      console.log("Refund initiated:", refund.id, refund.status);
      // TODO: mark the order as "refund pending" in your database
      break;
    }

    case "refund.updated": {
      const refund = event.data.object as Stripe.Refund;
      if (refund.status === "succeeded") {
        console.log("Refund completed:", refund.id);
        // TODO: mark the order as refunded, notify the customer by email
      }
      break;
    }

    case "refund.failed": {
      const refund = event.data.object as Stripe.Refund;
      console.warn("Refund failed:", refund.id, refund.failure_reason);

      if (refund.failure_reason === "charge_for_pending_refund_disputed") {
        // A dispute landed on this charge while the refund was still in
        // flight. Don't retry the refund — go handle the dispute instead.
      } else {
        // TODO: alert your team — this needs manual follow-up, the customer wasn't paid back
      }
      break;
    }

    default:
      break;
  }

  return NextResponse.json({ received: true }, { status: 200 });
}

What's happening here:

  • refund.created, refund.updated, and refund.failed now fire for every refund type — card, bank transfer, wallet — not just refunds without an associated charge. This changed in a Stripe API update; on older pinned API versions, you may only see the equivalent information inside a charge.refunded event instead. If you're not sure which version you're on, check Stripe-Version in your Dashboard settings.
  • req.text() reads the raw body — exactly like every other Stripe webhook handler on this site. Calling req.json() first breaks signature verification.
  • Returning 200 for events you don't act on (the default case) matters — Stripe retries anything that doesn't get a 2xx response, and you don't want it retrying forever over an event type you're intentionally ignoring.

⚠️ Common Mistake: Marking an order "refunded" the moment your Server Action's stripe.refunds.create() call resolves, instead of waiting for the refund.updated webhook with status: "succeeded". A refund can still fail after being accepted — insufficient balance, an invalid destination — and your database would say "refunded" for money the customer never actually received.

Step 7: Prevent Duplicate Processing

Just like payment webhooks, refund webhooks can be delivered more than once. Reuse the same idempotent-event pattern: store processed event IDs with a unique constraint, and skip anything you've already handled.

-- Reuse the same table if you already track processed payment webhook events
create table if not exists processed_webhook_events (
  id uuid primary key default gen_random_uuid(),
  stripe_event_id text unique not null,
  processed_at timestamptz default now()
);
// Inside the switch statement, before doing real work
const alreadyProcessed = await hasProcessedEvent(event.id);
if (alreadyProcessed) {
  return NextResponse.json({ received: true, duplicate: true });
}

// ... handle the event ...

await markEventProcessed(event.id);

💡 If you haven't set this up yet, our Stripe webhook idempotency guide covers the full pattern — signature verification, idempotency keys, and duplicate-event tracking — in more depth than this post has room for.


Part 3: Disputes

Step 8: What Happens When a Dispute Is Filed

A dispute is a formal claim a cardholder files with their bank, not with you. The moment it's created:

  1. Stripe immediately debits the disputed amount, plus a separate dispute fee, from your account balance.
  2. Stripe fires a charge.dispute.created webhook — this is usually how you find out at all.
  3. You get a limited window to submit evidence proving the charge was legitimate. The exact deadline is in dispute.evidence_details.due_by and varies by card network — don't hardcode a number of days.
  4. If you don't respond in time, the dispute is automatically decided against you and the funds stay with the customer's bank.

⚠️ Don't try to refund your way out of a dispute. Once a dispute exists, the bank has already reimbursed the customer directly — issuing your own refund on top of that doesn't reverse the chargeback, it just risks paying the customer twice. Stripe's guidance is explicit that a formally disputed payment should be addressed through the dispute process (evidence or acceptance), not a manual refund. There's also one automated backstop worth knowing: if you'd already started a refund and a dispute is filed on that same charge while the refund is still pending, Stripe fails the refund on its own with failure_reason: "charge_for_pending_refund_disputed" — see Step 5. That backstop only covers that specific race condition, though, not every case of refunding an already-disputed charge, so don't rely on the API to stop you — check for an open dispute yourself before refunding.

Step 9: Listen for Dispute Webhooks

Disputes go through their own lifecycle of events, separate from refunds:

// app/api/webhook/route.ts (continued — same switch statement as Step 6)
case "charge.dispute.created": {
  const dispute = event.data.object as Stripe.Dispute;

  console.log("New dispute:", dispute.id, dispute.reason, dispute.amount);
  console.log("Evidence due by:", new Date(dispute.evidence_details.due_by * 1000));

  // TODO: store the dispute, alert your team immediately — the clock is already running
  break;
}

case "charge.dispute.updated": {
  const dispute = event.data.object as Stripe.Dispute;
  console.log("Dispute updated:", dispute.id, dispute.status);
  // TODO: sync the latest status/evidence state to your database
  break;
}

case "charge.dispute.closed": {
  const dispute = event.data.object as Stripe.Dispute;
  console.log("Dispute closed:", dispute.id, dispute.status); // "won" or "lost"
  // TODO: if "won", funds return to your balance automatically — just update your records
  break;
}

case "charge.dispute.funds_withdrawn": {
  const dispute = event.data.object as Stripe.Dispute;
  // Fires when the disputed amount + fee is actually deducted from your balance
  break;
}

case "charge.dispute.funds_reinstated": {
  const dispute = event.data.object as Stripe.Dispute;
  // Fires when you win and the funds are returned
  break;
}

What's happening here: dispute.reason tells you why the cardholder is disputing (fraudulent, product_not_received, duplicate, and several others), which is useful for automatically routing the dispute to the right evidence template. evidence_details.due_by is a Unix timestamp — multiply by 1000 before passing it to new Date() in JavaScript.

💡 Tip: Some card networks send pre-dispute alerts before a formal dispute is even filed — a chance to refund proactively and avoid the dispute (and its fee) entirely. These arrive as separate events from your fraud/alert tooling, not charge.dispute.created, so don't assume silence means you're safe until the formal dispute webhook actually fires.

Step 10: Submit Dispute Evidence Through the API

You can respond to a dispute entirely from your dashboard, but doing it through the API means you can pull evidence — order details, tracking numbers, support conversation logs — directly from your own database instead of copy-pasting into a web form.

// actions/submit-dispute-evidence.ts
"use server";

import { stripe } from "@/lib/stripe/server";
import type Stripe from "stripe";

interface EvidenceInput {
  disputeId: string;
  productDescription: string;
  customerName: string;
  customerEmailAddress: string;
  shippingTrackingNumber?: string;
  customerCommunication?: string;
  uncategorizedText?: string;
  submit: boolean; // false = stage evidence for review, true = send it to the bank now
}

export async function submitDisputeEvidence(input: EvidenceInput) {
  try {
    const evidence: Stripe.DisputeUpdateParams.Evidence = {
      product_description: input.productDescription,
      customer_name: input.customerName,
      customer_email_address: input.customerEmailAddress,
      shipping_tracking_number: input.shippingTrackingNumber,
      customer_communication: input.customerCommunication,
      uncategorized_text: input.uncategorizedText,
    };

    const dispute = await stripe.disputes.update(input.disputeId, {
      evidence,
      submit: input.submit,
    });

    return { success: true, status: dispute.status };
  } catch (err) {
    return {
      error: err instanceof Error ? err.message : "Failed to submit dispute evidence.",
    };
  }
}

What's happening here:

  • stripe.disputes.update() is used for both staging and submitting evidence — the submit boolean controls which one happens. submit: false (the default if you omit it) saves your evidence as a draft you can keep editing; submit: true sends it to the bank immediately and starts the review clock.
  • Every field inside evidence is optional — Stripe only checks that the fields required for that dispute's reason are present at the moment you actually submit. A fraudulent dispute and a product_not_received dispute expect different evidence, so don't assume one fixed field set covers every case.
  • Updating any field inside evidence resends the entire evidence hash for review — you're not doing a partial patch, you're replacing the evidence Stripe has on file with what you send.

⚠️ Common Mistake: Calling submit: true before you're actually ready. Unlike staged evidence, a submitted dispute response can't be edited or resubmitted with better evidence later — treat it as final, and use submit: false while you're still gathering documentation.

Step 11: Closing a Dispute You Don't Want to Fight

If you genuinely have no evidence and don't want to contest a dispute, close it explicitly instead of letting it time out:

// actions/close-dispute.ts
"use server";

import { stripe } from "@/lib/stripe/server";

export async function closeDispute(disputeId: string) {
  try {
    const dispute = await stripe.disputes.close(disputeId);
    return { success: true, status: dispute.status };
  } catch (err) {
    return { error: err instanceof Error ? err.message : "Failed to close dispute." };
  }
}

Closing a dispute is an explicit acknowledgment that you're accepting the loss — functionally similar to not responding, but it's a deliberate action in your own system rather than a deadline quietly passing.


Testing Locally with the Stripe CLI

Both refunds and disputes are testable without touching real money.

Forward Webhooks

stripe listen --forward-to localhost:3000/api/webhook

Copy the printed signing secret into .env.local as STRIPE_WEBHOOK_SECRET, then restart your dev server.

Trigger a Test Dispute

stripe trigger charge.dispute.created

This creates a real (test-mode) dispute against a test charge and fires the webhook — watch your terminal running stripe listen to confirm your handler picks it up.

Trigger a Test Refund

There's no single stripe trigger fixture for refunds, so create one directly against a test Payment Intent instead:

stripe payment_intents create --amount=2000 --currency=usd --payment-method=pm_card_visa --confirm=true
# copy the returned Payment Intent ID, then:
stripe refunds create --payment-intent=pi_xxxxx

With stripe listen running in another terminal, you'll see refund.created and refund.updated come through exactly as they would in production.

💡 Tip: stripe trigger --help lists every fixture event the CLI can simulate directly — useful for testing charge.dispute.closed or charge.dispute.funds_reinstated without waiting for a real dispute to resolve.


Production Checklist

  • Refund Server Actions pass an idempotencyKey, generated once per user action
  • UI never marks an order "refunded" before the refund.updated webhook confirms status: "succeeded"
  • Refund webhook events (refund.created, refund.updated, refund.failed) are handled, with charge.refunded as a fallback if you're on an older pinned API version
  • All five dispute events are handled: charge.dispute.created, .updated, .closed, .funds_withdrawn, .funds_reinstated
  • Processed webhook event IDs are stored with a unique database constraint to prevent duplicate processing
  • charge.dispute.created triggers an immediate alert to a real human — evidence deadlines don't wait
  • evidence_details.due_by is surfaced somewhere your team actually checks, not buried in Stripe's Dashboard alone
  • Evidence is staged (submit: false) while still being gathered, and only submitted (submit: true) once final
  • Live-mode webhook secret and API keys are separate from test-mode values, and never NEXT_PUBLIC_

Frequently Asked Questions

Can I refund a payment that's already been disputed?

You technically can call the API, but you shouldn't. Stripe's own guidance says a formally disputed payment should be handled through the dispute process, not a direct refund — the bank has already reimbursed the customer, so refunding on top of that risks paying them twice. The API's automated protection is narrower than "you can't refund a disputed charge" — it only catches the specific case where a dispute is filed on a charge while your refund for that charge is still pending, which fails the refund with failure_reason: "charge_for_pending_refund_disputed". Check for an open dispute yourself before issuing a refund rather than counting on the API to block it.

How long do I have to respond to a dispute?

It varies by card network and isn't a fixed number of days — always check dispute.evidence_details.due_by on the actual Dispute object rather than assuming a standard window.

What's the difference between refund.updated and charge.refunded?

refund.updated fires specifically for status changes on the Refund object itself and now covers every refund type. charge.refunded fires on the parent Charge object and is the event you'd rely on if you're pinned to an older Stripe API version that predates the expanded refund events.

Does a partial refund protect me if the customer disputes the rest?

It can help your case, but it doesn't prevent a dispute from being filed for the remaining (or even the full) amount. If that happens, submit evidence showing the partial refund you already issued as part of your response.

Can I edit dispute evidence after submitting it?

No — once you call stripe.disputes.update() with submit: true, that response is final. Keep submit: false while you're still collecting documentation, and only submit once everything is ready.

Do I need a database, or can I rely on the Stripe Dashboard alone?

For anything beyond a handful of transactions, yes. The Dashboard is fine for occasional manual lookups, but reacting to dispute deadlines and keeping order status in sync in real time requires your own webhook-driven records — the patterns in this guide.


Wrapping Up

Refunds and disputes aren't the scary edge case they're often treated as — refunds are a Server Action with an idempotency key, and disputes are a webhook listener plus an evidence-submission call. The part that actually matters is discipline: never trust a refund is complete until the webhook confirms it, and never let a dispute deadline pass silently because nobody was watching for charge.dispute.created.

From here, pair this with the Stripe webhook idempotency guide if you haven't locked down signature verification and duplicate-event handling yet, and revisit your subscription billing webhooks to make sure refund and dispute events aren't silently falling through a default case you forgot to update.

Continue Learning

Useful Resources

StripeRefundsDisputesChargebacksNext.js 16WebhooksTypeScriptServer Actions
Share On