Handling Stripe Disputes and Chargebacks in Laravel

Handle Stripe chargebacks in Laravel — listen for dispute webhooks, collect evidence from your database, and submit via the Evidence API before the deadline.

Steven Richardson
Steven Richardson
· 8 min read

Chargebacks are inevitable. At some point a customer disputes a charge, Stripe notifies you, and you have a narrow window to respond with evidence before you automatically lose. Most Laravel apps handle payments fine but have zero automation around disputes — the notification arrives by email, someone scrambles to find evidence, and the deadline passes.

Stripe's dispute webhooks and Evidence API let you automate most of this. Here's how to wire it up.

The Stripe Disputes Lifecycle#

When a customer disputes a charge, Stripe fires a sequence of webhook events:

  • charge.dispute.created — a dispute is opened; funds are immediately held
  • charge.dispute.funds_withdrawn — the disputed amount is debited from your balance
  • charge.dispute.updated — evidence was added or the dispute status changed
  • charge.dispute.closed — the dispute is resolved (won or lost)
  • charge.dispute.funds_reinstated — you won; funds return to your balance

The dispute object has a status field that progresses through: needs_responseunder_reviewwon or lost. There's also a warning_needs_response status for pre-dispute inquiries from some card networks, where you have a smaller window to resolve things without a formal chargeback.

The critical field is evidence_details.due_by — a Unix timestamp of your response deadline. This is typically 7–21 days depending on the card network. Miss it and the dispute auto-closes as lost.

First, add a table to track disputes:

php artisan make:migration create_disputes_table
public function up(): void
{
    Schema::create('disputes', function (Blueprint $table): void {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->string('stripe_dispute_id')->unique();
        $table->string('stripe_charge_id');
        $table->string('status');
        $table->string('reason');
        $table->unsignedBigInteger('amount'); // in smallest currency unit
        $table->string('currency', 3);
        $table->timestamp('due_by')->nullable();
        $table->boolean('evidence_submitted')->default(false);
        $table->timestamps();
    });
}

Webhook Setup for Stripe Disputes in Laravel#

Register a webhook endpoint in your Stripe dashboard under Developers → Webhooks, listening for at minimum: charge.dispute.created, charge.dispute.updated, charge.dispute.closed, charge.dispute.funds_reinstated.

The signature verification pattern is the same as for platform webhooks — if you haven't already read how Stripe webhook signature verification works in Laravel, that's the foundation for everything here.

// app/Http/Controllers/DisputeWebhookController.php

use App\Models\Dispute;
use App\Services\DisputeEvidenceService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;

class DisputeWebhookController extends Controller
{
    public function __construct(
        private readonly DisputeEvidenceService $evidenceService,
    ) {}

    public function handle(Request $request): Response
    {
        try {
            $event = Webhook::constructEvent(
                $request->getContent(),
                $request->header('Stripe-Signature'),
                config('services.stripe.webhook_secret')
            );
        } catch (SignatureVerificationException) {
            return response('Invalid signature', 400);
        }

        match ($event->type) {
            'charge.dispute.created' => $this->handleDisputeCreated($event->data->object),
            'charge.dispute.updated' => $this->handleDisputeUpdated($event->data->object),
            'charge.dispute.closed'  => $this->handleDisputeClosed($event->data->object),
            default                  => null,
        };

        return response('OK', 200);
    }

    private function handleDisputeCreated(\Stripe\Dispute $dispute): void
    {
        // Find the customer from the charge
        $charge = app(\Stripe\StripeClient::class)->charges->retrieve($dispute->charge);
        $user = \App\Models\User::where('stripe_customer_id', $charge->customer)->first();

        if (! $user) {
            return;
        }

        $record = Dispute::create([
            'user_id'           => $user->id,
            'stripe_dispute_id' => $dispute->id,
            'stripe_charge_id'  => $dispute->charge,
            'status'            => $dispute->status,
            'reason'            => $dispute->reason,
            'amount'            => $dispute->amount,
            'currency'          => $dispute->currency,
            'due_by'            => $dispute->evidence_details->due_by
                ? \Carbon\Carbon::createFromTimestamp($dispute->evidence_details->due_by)
                : null,
        ]);

        // Auto-collect and submit evidence immediately
        $this->evidenceService->collectAndSubmit($record, $dispute->id);
    }

    private function handleDisputeUpdated(\Stripe\Dispute $dispute): void
    {
        Dispute::where('stripe_dispute_id', $dispute->id)
            ->update(['status' => $dispute->status]);
    }

    private function handleDisputeClosed(\Stripe\Dispute $dispute): void
    {
        Dispute::where('stripe_dispute_id', $dispute->id)
            ->update(['status' => $dispute->status]);
    }
}

Register the route and exclude it from CSRF protection:

// routes/web.php
Route::post('/stripe/webhook/disputes', [DisputeWebhookController::class, 'handle'])
    ->name('stripe.disputes.webhook');
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: [
        'stripe/webhook',
        'stripe/webhook/disputes',
    ]);
})

Auto-Collecting Evidence#

The charge.dispute.created event is your trigger to pull together everything you have on this customer and this transaction. What Stripe accepts depends on the dispute reason, but the fields that win most SaaS disputes are: proof the customer used the service, their agreed refund policy, and communication history.

// app/Services/DisputeEvidenceService.php

use App\Models\Dispute;
use Stripe\StripeClient;

class DisputeEvidenceService
{
    public function __construct(private readonly StripeClient $stripe) {}

    public function collectAndSubmit(Dispute $dispute, string $stripeDisputeId): void
    {
        $user = $dispute->user;

        $evidence = [
            // Customer identity
            'customer_email_address' => $user->email,
            'customer_name'          => $user->name,

            // What they paid for
            'product_description' => sprintf(
                '%s subscription — %s plan, active since %s.',
                config('app.name'),
                $user->subscription?->plan?->name ?? 'standard',
                $user->created_at->toFormattedDateString()
            ),

            // Your refund policy (text or URL)
            'refund_policy_disclosure' => 'Our refund policy is available at ' . url('/refund-policy'),
            'refund_refusal_explanation' => sprintf(
                'Customer has used the service since %s. The subscription is active and the account is in good standing. No refund request was received prior to this dispute.',
                $user->created_at->toFormattedDateString()
            ),

            // SaaS: show the customer actually used the service
            'access_activity_log' => $this->buildActivityLog($user),
        ];

        // Stage the evidence first (submit: false) so you can review it in the dashboard
        $this->stripe->disputes->update($stripeDisputeId, [
            'evidence' => $evidence,
            'submit'   => false,
        ]);

        $dispute->update(['evidence_submitted' => false]);
    }

    public function submit(Dispute $dispute): void
    {
        $this->stripe->disputes->update($dispute->stripe_dispute_id, [
            'submit' => true,
        ]);

        $dispute->update(['evidence_submitted' => true]);
    }

    private function buildActivityLog(\App\Models\User $user): string
    {
        // Pull recent login/activity events from your own audit log
        $recentActivity = $user->activityLogs()
            ->latest()
            ->limit(20)
            ->get()
            ->map(fn ($log) => sprintf(
                '%s: %s from IP %s',
                $log->created_at->toDateTimeString(),
                $log->action,
                $log->ip_address
            ))
            ->join("\n");

        return $recentActivity ?: 'No recent activity logs available.';
    }
}

The call with submit: false stages the evidence — it's visible in the Stripe dashboard but not yet sent to the card network. This lets you review it before submitting. When you're ready, call submit() or build a dashboard action for your team to trigger it manually.

This matches the same event-gating pattern used throughout Stripe subscription lifecycle webhook handling in Laravel — always verify the object's state fields rather than assuming the event alone tells the full story.

Submitting Evidence Programmatically#

When you're confident in the auto-collected evidence (or after a manual review), submit it:

// In a controller action or queued job
public function submit(Dispute $dispute): RedirectResponse
{
    $this->evidenceService->submit($dispute);

    return redirect()->back()->with('status', 'Evidence submitted to Stripe.');
}

Or dispatch a job to submit automatically after a short delay (giving your team time to intervene):

// In handleDisputeCreated, after collecting evidence
\App\Jobs\SubmitDisputeEvidence::dispatch($dispute)->delay(now()->addHours(4));
// app/Jobs/SubmitDisputeEvidence.php
class SubmitDisputeEvidence implements ShouldQueue
{
    public function __construct(public readonly Dispute $dispute) {}

    public function handle(DisputeEvidenceService $service): void
    {
        // Only submit if still in needs_response and evidence not yet submitted
        if ($this->dispute->status === 'needs_response' && ! $this->dispute->evidence_submitted) {
            $service->submit($this->dispute);
        }
    }
}

Fraud Prevention Best Practices#

Evidence submission is reactive. Radar rules and 3D Secure are proactive — they prevent fraudulent charges from succeeding in the first place.

3D Secure (liability shift): When 3DS authentication succeeds, liability shifts to the card issuer. They cannot win a fraud chargeback against you. You can require 3DS via Radar or by setting request_three_d_secure on the PaymentIntent:

$stripe->paymentIntents->create([
    'amount'   => 4999,
    'currency' => 'gbp',
    'payment_method_options' => [
        'card' => [
            'request_three_d_secure' => 'automatic', // or 'any' to always force it
        ],
    ],
]);

Radar rules: Block or flag payments that show fraud signals before they complete. In the Stripe dashboard under Radar → Rules, common additions:

  • Block if CVC check fails
  • Block if ZIP/postal code check fails for billing address
  • Review charges above a threshold from new customers

Free trials and fraud: Trial subscriptions attract card testing — people use stolen cards to verify them with low-value trial charges. If you're running trials, check out the subscription trial patterns covered here — collecting card upfront with a £0 setup intent and enabling Radar helps filter these out before disputes occur.

Gotchas and Edge Cases#

charge.dispute.created fires immediately — including nights and weekends. Your webhook handler must be resilient. Use queued jobs for the evidence collection work, not synchronous processing in the controller.

One dispute per charge. You can only dispute each charge once. If you accept liability (close the dispute as lost via the API), you cannot reopen it.

Platform disputes on Connect accounts. If you're using Stripe Connect for marketplace payments, disputes on connected account charges behave differently. The dispute lands on the connected account, not your platform account. You'll need a separate Connect webhook endpoint — the same pattern as your platform webhooks but pointed at your Connect webhook secret.

warning_needs_response is not a full dispute. Some card networks send an inquiry first. Respond to these too — they're cheaper to defend and prevent a full chargeback if you resolve them. Your handler should treat warning_needs_response as urgently as needs_response.

The combined character limit on evidence is 150,000. If your activity log is verbose, truncate it. Stripe rejects updates that exceed this limit silently in some SDK versions.

Smart Disputes (Stripe-managed): Stripe now offers Smart Disputes, which automatically compiles and submits evidence packets on your behalf. It's worth enabling in the dashboard alongside your automation — it handles cases where your own evidence collection comes up short. Your programmatic submission still takes priority if you submit first.

Wrapping Up#

The core loop is: listen for charge.dispute.created, immediately record the dispute and collect evidence, then either auto-submit after a review window or build a manual trigger for your team. With due_by tracked in the database you can also send internal alerts well before the deadline.

From here you can round out your billing automation with a Stripe Customer Portal for self-service billing management, so customers who want a refund can handle it themselves before reaching the chargeback stage.

FAQ#

Can I reopen a dispute after I've lost or accepted liability for it?

No, once a dispute closes as lost or you accept liability, it's final. You cannot reopen or refile the same charge. Prevention through activity logs and 3D Secure is your best defence.

What evidence matters most for SaaS disputes?

Proof the customer used the service (login records, API activity, exported data) usually wins. Add your refund policy, the user's active account status since purchase, and communication history. Card network rules vary, but service usage logs are the single strongest piece of evidence for recurring SaaS.

Do I have to auto-submit evidence, or can I review it first?

You can stage evidence with submit: false, review it in the Stripe dashboard, and manually trigger submission when ready. This gives your team a chance to add context or redact sensitive data before the card network sees it.

What's the difference between a dispute and a warning_needs_response status?

warning_needs_response is an inquiry from some card networks before a full chargeback. The window is smaller (typically 3–5 days) but winning these pre-disputes is cheaper and prevents a formal chargeback. Treat them just as urgently as a full dispute.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.