Fraud Prevention 12 min read

Webhook Patterns for Stripe Fraud Prevention: Building Real-Time Risk Pipelines

Matt King
Matt King

May 13, 2026

Webhook Patterns for Stripe Fraud Prevention: Building Real-Time Risk Pipelines

Stripe processes millions of transactions every day, and each one generates a stream of events. These events, delivered as webhooks, are your best opportunity to intercept fraud in real time. Instead of reviewing transactions manually or waiting for chargebacks to arrive, you can build an automated risk pipeline that evaluates every payment the moment it happens.

This guide walks through the architecture, implementation, and operational patterns for building a production-grade fraud prevention pipeline using Stripe webhooks, Laravel, and Fidro's risk scoring API.

Why Webhooks Matter for Fraud Prevention

Most fraud detection happens at one of two points: before the transaction (pre-auth) or after the chargeback (too late). Webhooks give you a third option, which is reacting to events in real time as they flow through Stripe.

The advantages are significant:

  • Speed. Webhooks fire within seconds of an event. You can assess risk and take action before the customer even sees a confirmation page.
  • Coverage. Stripe emits events for every stage of the payment lifecycle. You get visibility into payment creation, charge completion, disputes, refunds, and customer changes.
  • Automation. Once your pipeline is running, every transaction gets the same level of scrutiny. No manual review queues, no missed patterns, no gaps during off-hours.
  • Decoupling. Your fraud logic runs independently of your checkout flow. You can update risk rules, add new signals, or change thresholds without modifying your payment integration.

The average chargeback costs merchants $190 in fees and lost revenue (Chargebacks911, 2024). A single webhook pipeline can eliminate the majority of those losses by catching high-risk transactions before they settle.

Key Stripe Webhook Events for Fraud Detection

Not every Stripe event is relevant to fraud. Here are the ones that matter most, organized by when they fire in the transaction lifecycle:

Pre-Payment Events

  • payment_intent.created. Fires when a new payment intent is created. This is your earliest interception point. You can assess the customer's risk profile and cancel the intent before any charge attempt.
  • customer.created. Fires when a new Stripe customer is created. Useful for validating the customer's email and metadata before they make any purchases.

Payment Events

  • charge.succeeded. Fires when a charge completes successfully. If your pre-payment checks missed something, this is your second chance to flag or refund.
  • payment_intent.succeeded. Fires when the full payment intent completes. Contains the final transaction amount, payment method details, and billing address.

Dispute Events

  • charge.dispute.created. Fires when a customer initiates a chargeback. Critical for tracking dispute rates and feeding data back into your risk model.
  • charge.dispute.closed. Fires when a dispute resolves. Track win/loss rates to calibrate your risk thresholds.

Refund Events

  • charge.refunded. Fires when a refund is issued. Track which refunds were triggered by your fraud pipeline versus manual actions.

Architecture: Webhook to Risk Assessment to Action

The pipeline follows a straightforward three-stage pattern: Stripe Event → Webhook Endpoint → Queue Job → Risk Assessment → Automated ActionStage 1: Receive and acknowledge. Your webhook endpoint validates the Stripe signature, stores the raw event, and dispatches a queue job. Return a 200 response immediately.

Stage 2: Assess risk. The queue job extracts customer data from the event (email, IP address, billing country, transaction amount) and sends it to your risk scoring engine. This is where Fidro's API evaluates the transaction against 14+ risk factors.

Stage 3: Act on the result. Based on the risk score and recommendation, your pipeline takes automated action: allow the transaction, flag it for review, issue an automatic refund, or block the customer.

Implementation with Laravel

Step 1: Register the Webhook Route

Create a dedicated route for Stripe webhooks. This should bypass CSRF verification since Stripe cannot send CSRF tokens.

// routes/api.php Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle']) ->name('webhooks.stripe');### Step 2: Verify the Stripe Signature

Never trust a webhook payload without verifying its signature. Stripe signs every webhook with your endpoint's signing secret.

// app/Http/Controllers/StripeWebhookController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request; use Stripe\Webhook; use Stripe\Exception\SignatureVerificationException; use App\Jobs\ProcessStripeEvent;

class StripeWebhookController extends Controller { public function handle(Request $request) { $payload = $request->getContent(); $sigHeader = $request->header('Stripe-Signature'); $secret = config('services.stripe.webhook_secret');

    try {
        $event = Webhook::constructEvent($payload, $sigHeader, $secret);
    } catch (SignatureVerificationException $e) {
        return response('Invalid signature', 400);
    }

    // Dispatch to queue for async processing
    ProcessStripeEvent::dispatch($event->toArray());

    return response('OK', 200);
}

}The key principle here is speed. Validate the signature, dispatch the job, and return immediately. All risk assessment happens in the background.

Step 3: Build the Queue Job

The queue job is where the real work happens. It extracts the relevant data from the Stripe event and routes it to the appropriate handler.

// app/Jobs/ProcessStripeEvent.php

namespace App\Jobs;

use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use App\Services\FraudPipeline;

class ProcessStripeEvent implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public int $tries = 3;
public int $backoff = 30;

public function __construct(
    private array $event
) {}

public function handle(FraudPipeline $pipeline): void
{
    $type = $this->event['type'];

    match ($type) {
        'payment_intent.created' => $pipeline->assessPaymentIntent($this->event['data']['object']),
        'charge.succeeded' => $pipeline->assessCharge($this->event['data']['object']),
        'customer.created' => $pipeline->assessCustomer($this->event['data']['object']),
        'charge.dispute.created' => $pipeline->handleDispute($this->event['data']['object']),
        default => null,
    };
}

}Notice the $tries and $backoff properties. Webhook processing should be resilient. If the risk API is temporarily unavailable, the job retries after 30 seconds.

Step 4: Enrich with Fidro's API

This is where your pipeline gains real intelligence. Stripe gives you payment data, but it cannot tell you whether the customer's email is disposable, whether their IP is a known VPN exit node, or whether their billing country matches their actual location.

// app/Services/FraudPipeline.php

namespace App\Services;

use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Stripe\StripeClient;

class FraudPipeline { private StripeClient $stripe;

public function __construct()
{
    $this->stripe = new StripeClient(config('services.stripe.secret'));
}

public function assessPaymentIntent(array $paymentIntent): void
{
    $customer = $this->stripe->customers->retrieve(
        $paymentIntent['customer']
    );

    $email = $customer->email;
    $ipAddress = $paymentIntent['metadata']['ip_address'] ?? null;
    $countryCode = $paymentIntent['shipping']['address']['country']
        ?? $paymentIntent['metadata']['country_code']
        ?? null;

    // Call Fidro's validation API
    $response = Http::withToken(config('services.fidro.api_key'))
        ->post('https://api.fidro.io/v1/validate', [
            'email' => $email,
            'ip' => $ipAddress,
            'country_code' => $countryCode,
        ]);

    if ($response->failed()) {
        Log::warning('Fidro API call failed', [
            'payment_intent' => $paymentIntent['id'],
            'status' => $response->status(),
        ]);
        return;
    }

    $risk = $response->json();

    $this->actOnRisk($risk, $paymentIntent);
}

private function actOnRisk(array $risk, array $paymentIntent): void
{
    $score = $risk['risk_score'];
    $recommendation = $risk['recommendation'];

    // Log every assessment for audit trail
    Log::info('Fraud assessment completed', [
        'payment_intent' => $paymentIntent['id'],
        'risk_score' => $score,
        'recommendation' => $recommendation,
        'checks' => $risk['checks'],
    ]);

    match ($recommendation) {
        'block' => $this->blockTransaction($paymentIntent, $risk),
        'challenge' => $this->flagForReview($paymentIntent, $risk),
        'allow' => $this->allowTransaction($paymentIntent, $risk),
    };
}

}Fidro returns a risk score from 0 to 100 along with a clear recommendation: allow, challenge, or block. The checks object tells you exactly which risk factors triggered, so you can build detailed audit logs.

Automated Response Actions

The final piece of the pipeline is deciding what to do with each risk level. Here are the three response patterns:

Block: Auto-Refund High-Risk Transactions

For transactions scoring 80 or above, cancel the payment intent if it has not yet been captured, or issue an immediate refund.

private function blockTransaction(array $paymentIntent, array $risk): void { $intentId = $paymentIntent['id'];

try {
    // Cancel if not yet captured
    if ($paymentIntent['status'] === 'requires_capture') {
        $this->stripe->paymentIntents->cancel($intentId);
    } else {
        // Refund if already captured
        $this->stripe->refunds->create([
            'payment_intent' => $intentId,
            'reason' => 'fraudulent',
        ]);
    }
} catch (\Exception $e) {
    Log::error('Failed to block transaction', [
        'payment_intent' => $intentId,
        'error' => $e->getMessage(),
    ]);
}

// Add customer to blocklist via Fidro
Http::withToken(config('services.fidro.api_key'))
    ->post('https://api.fidro.io/v1/blocklist', [
        'type' => 'email',
        'value' => $paymentIntent['metadata']['customer_email'] ?? '',
        'reason' => 'Auto-blocked: risk score ' . $risk['risk_score'],
    ]);

// Notify the team
$this->notifyTeam('blocked', $paymentIntent, $risk);

}### Challenge: Flag for Manual Review

Transactions scoring between 50 and 79 get flagged for human review. You want a team member to make the final call, but you also want to make sure nothing slips through.

private function flagForReview(array $paymentIntent, array $risk): void { // Store in your review queue (database, dashboard, etc.) FraudReview::create([ 'payment_intent_id' => $paymentIntent['id'], 'customer_id' => $paymentIntent['customer'], 'amount' => $paymentIntent['amount'], 'risk_score' => $risk['risk_score'], 'risk_checks' => json_encode($risk['checks']), 'status' => 'pending', ]);

// Add metadata to the Stripe payment for visibility
$this->stripe->paymentIntents->update($paymentIntent['id'], [
    'metadata' => [
        'fraud_score' => $risk['risk_score'],
        'fraud_status' => 'review',
    ],
]);

$this->notifyTeam('flagged', $paymentIntent, $risk);

}### Allow: Log and Move On

Low-risk transactions still get logged. You want a complete audit trail, and the data feeds back into your understanding of what "normal" looks like.

private function allowTransaction(array $paymentIntent, array $risk): void { $this->stripe->paymentIntents->update($paymentIntent['id'], [ 'metadata' => [ 'fraud_score' => $risk['risk_score'], 'fraud_status' => 'allowed', ], ]); }## Handling Disputes as Feedback

When a charge.dispute.created event arrives, it means your pipeline missed something, or the dispute is friendly fraud. Either way, the data is valuable.

public function handleDispute(array $dispute): void { $chargeId = $dispute['charge']; $charge = $this->stripe->charges->retrieve($chargeId);

Log::warning('Dispute received', [
    'charge_id' => $chargeId,
    'amount' => $dispute['amount'],
    'reason' => $dispute['reason'],
    'customer_email' => $charge->billing_details->email,
]);

// Auto-add to blocklist to prevent repeat offenders
if ($charge->billing_details->email) {
    Http::withToken(config('services.fidro.api_key'))
        ->post('https://api.fidro.io/v1/blocklist', [
            'type' => 'email',
            'value' => $charge->billing_details->email,
            'reason' => 'Dispute received: ' . $dispute['reason'],
        ]);
}

$this->notifyTeam('dispute', ['charge' => $chargeId], [
    'risk_score' => 'N/A (post-dispute)',
    'reason' => $dispute['reason'],
]);

}Every dispute becomes a new blocklist entry, preventing the same customer from making future purchases. Over time, your blocklist becomes a powerful layer of protection that is unique to your business.

Operational Best Practices

Store raw events. Before processing any webhook, save the raw JSON payload to your database. This gives you a complete audit trail and lets you replay events if your processing logic changes.

Use idempotency keys. Stripe may deliver the same webhook more than once. Use the event ID as an idempotency key to avoid processing duplicates.

if (WebhookEvent::where('stripe_event_id', $event['id'])->exists()) { return; // Already processed }

WebhookEvent::create([ 'stripe_event_id' => $event['id'], 'type' => $event['type'], 'payload' => json_encode($event), 'processed_at' => now(), ]);Monitor your queue. Failed jobs mean missed fraud checks. Set up alerts for queue failures and track processing latency. Your pipeline is only as reliable as your queue infrastructure.

Tune your thresholds. Start with Fidro's defaults (41 for review, 71 for auto-refund) and adjust based on your dispute rate. If you are seeing too many false positives, raise the thresholds. If chargebacks are still getting through, lower them.

Pass the IP address. The most common mistake in webhook-based fraud detection is not having the customer's IP address available. Capture it during checkout and store it in the payment intent's metadata so your pipeline can use it. javascript // During checkout (client-side) const paymentIntent = await stripe.confirmPayment({ elements, confirmParams: { payment_method_data: { metadata: { ip_address: customerIpAddress, country_code: detectedCountry, }, }, }, });## Putting It All Together

A production fraud pipeline built on these patterns gives you real-time risk assessment on every transaction, automated responses for clear-cut cases, human review for borderline situations, and a feedback loop that strengthens your defenses over time.

The combination of Stripe's event stream and Fidro's risk scoring covers both sides of the equation: Stripe tells you what happened, and Fidro tells you whether it looks suspicious. Together, they give you the data you need to act before chargebacks hit your account.

If you are running a Stripe-based business and your chargeback rate is creeping toward 1%, this pipeline pattern is the most effective way to bring it back down. Start on the free plan to test Fidro's API with your Stripe webhooks, and scale up as your transaction volume grows.

Frequently Asked Questions

Which Stripe webhook events should I listen to for fraud prevention?

The most important events are payment_intent.created, charge.succeeded, charge.dispute.created, charge.refunded, and customer.created. These cover the full transaction lifecycle and give you multiple interception points to assess risk before and after payment.

How do I verify Stripe webhook signatures in Laravel?

Use the Stripe PHP SDK's Webhook::constructEvent method with your webhook signing secret. This verifies the payload signature and ensures the request genuinely originated from Stripe. Never process webhook payloads without verifying the signature first.

Can I block a payment after receiving a webhook?

Yes. When you receive a payment_intent.created webhook, you can assess the risk and cancel the payment intent before it completes. For charges that have already succeeded, you can issue an automatic refund if the risk score exceeds your threshold.

How fast does the fraud pipeline need to respond?

Stripe expects a 2xx response to webhooks within 30 seconds. The best practice is to acknowledge the webhook immediately, then process the risk assessment in a background queue. Fidro's API responds in under 200ms on average, so the entire pipeline typically completes in under a second.

Should I use Stripe Radar alongside a custom fraud pipeline?

Yes. Stripe Radar provides a strong baseline with its built-in machine learning, but it only sees payment data. Adding Fidro to your pipeline lets you evaluate email validity, IP reputation, VPN usage, geolocation mismatches, and custom blocklists, giving you 14+ additional risk factors that Radar cannot access.