Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
LaravelPHPSaaS and CloudTutorial

Prevent duplicate payments and double webhook processing in Laravel

Stop double charges and double fulfilment in Laravel with Stripe and Razorpay: stable idempotency keys, unique constraints, locks, a webhook events table and reconciliation.

Cloudsoft Zone

Updated 13 min read

Card payment terminal with keypad and contactless reader beside a QR code scanner on a shop counter in Japan
Photograph: RuinDig/Yuki Uchida / CC BY 4.0, via Wikimedia Commons
In this article

Duplicate payments in a Laravel application come from two places: the checkout creating a second charge when a customer double-clicks, refreshes or retries, and the webhook handler fulfilling the same order twice because Stripe or Razorpay delivered an event more than once. You fix both with the same idea: make every step safe to repeat. That means stable idempotency keys towards the provider, a unique constraint and an atomic lock around order creation, a webhook_events table keyed by the provider's event id, queued processing that checks state before it acts, and a scheduled reconciliation job for whatever slips through. This article builds that in Laravel 13 for both Stripe and Razorpay.

Tested with: Laravel 13 on PHP 8.5, stripe/stripe-php 21.x and razorpay/razorpay 2.9.3. The webhook tests below send each event several times with valid and invalid signatures and assert one stored event and one fulfilment. If you use Node.js rather than Laravel, our Stripe Checkout and Node.js guide covers the same problem there.

What the providers actually guarantee

StripeRazorpay
Idempotent API requestsIdempotency-Key header on any v1 POST; keys up to 255 characters, may be pruned after at least 24 hours; reusing a key with different parameters is an errorOnly on the Payouts and composite APIs (X-Payout-Idempotency); no idempotency header for Orders or Payments
Webhook signatureStripe-Signature header, HMAC-SHA256 over timestamp.raw_body; libraries reject timestamps older than 5 minutes by defaultX-Razorpay-Signature header, HMAC-SHA256 of the raw body with the webhook secret
Duplicate deliveriesPossible; deduplicate on the event idAt-least-once; deduplicate on the X-Razorpay-Event-Id header
RetriesUp to three days with exponential backoff in live modeExponential backoff for 24 hours; the webhook is disabled if failures continue for 24 hours
OrderingNot guaranteedNot guaranteed
Response timeReturn 2xx quickly, before complex logicRespond within 5 seconds or the delivery counts as a timeout and is resent

Sources: Stripe's idempotent requests reference and webhooks guide, Razorpay's webhook validation and webhook best practices, and the payout idempotency reference. The practical consequence: with Stripe you get help from the API; with Razorpay orders, deduplication at checkout is entirely your application's job.

The data model

Two tables carry the guarantees. Orders store the provider's object id in a unique column, and webhook events are unique per provider and event id, so the database itself rejects duplicates even if two requests race.

PHP
<?php
// database/migrations/xxxx_xx_xx_create_payment_tables.php (up method)
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->uuid('public_id')->unique();
            $table->unsignedBigInteger('amount');          // minor units (cents, paise)
            $table->char('currency', 3);
            $table->string('status', 20)->default('pending'); // pending, paid, failed, refunded
            $table->string('provider', 20)->nullable();       // stripe, razorpay
            $table->string('provider_order_id')->nullable()->unique(); // pi_..., order_...
            $table->timestamp('paid_at')->nullable();
            $table->timestamp('fulfilled_at')->nullable();
            $table->timestamps();
        });

        Schema::create('webhook_events', function (Blueprint $table) {
            $table->id();
            $table->string('provider', 20);
            $table->string('event_id');                    // evt_... or X-Razorpay-Event-Id
            $table->string('type');
            $table->json('payload');
            $table->timestamp('processed_at')->nullable();
            $table->unsignedInteger('attempts')->default(0);
            $table->text('last_error')->nullable();
            $table->timestamps();

            $table->unique(['provider', 'event_id']);      // the deduplication guarantee
            $table->index(['processed_at', 'created_at']);
        });
    }

Store amounts in minor units (cents or paise) as integers, the same unit both APIs use, so there is no float rounding between your total and the charge.

Checkout: one provider order per local order

Diagram of four checkout layers: browser button disable, Cache::lock per order, reuse of the stored provider order, and the Stripe Idempotency-Key header
Four layers stop a second charge; with Razorpay orders the lock and the stored order id do the work of an idempotency header.

Disable the pay button after the first click, but do not rely on it: a second tab, a slow network or a back-button refresh still sends a second request. The server-side rule is simple: for a given local order there is at most one Stripe PaymentIntent or Razorpay order, created under a lock, stored, and reused on every later request.

Stripe

PHP
<?php

namespace App\Payments;

use App\Models\Order;
use Illuminate\Support\Facades\Cache;
use Stripe\StripeClient;

final class StripeCheckout
{
    public function __construct(private StripeClient $stripe) {}

    /** Returns the PaymentIntent client secret; safe to call repeatedly for one order. */
    public function clientSecretFor(Order $order): string
    {
        // Serialise concurrent requests for the same order (double clicks, two tabs).
        return Cache::lock("checkout:{$order->id}", 15)->block(5, function () use ($order) {
            $order->refresh();

            if ($order->provider_order_id !== null) {
                return $this->stripe->paymentIntents->retrieve($order->provider_order_id)->client_secret;
            }

            $intent = $this->stripe->paymentIntents->create([
                'amount' => $order->amount,
                'currency' => strtolower($order->currency),
                'metadata' => ['order_id' => $order->public_id],
                'automatic_payment_methods' => ['enabled' => true],
            ], [
                // Same order, same key: a retried request returns the same PaymentIntent.
                'idempotency_key' => "order-{$order->public_id}-pi-create",
            ]);

            $order->update(['provider' => 'stripe', 'provider_order_id' => $intent->id]);

            return $intent->client_secret;
        });
    }
}

The idempotency key is derived from the order, not generated per request. That detail matters. stripe-php adds its own random Idempotency-Key to v1 POST requests when you enable max_network_retries, which protects a single call against network failures, but a new call from a double click or a retried queue job gets a new random key and therefore a new PaymentIntent. A key such as order-{uuid}-pi-create makes every attempt for that order return the same object. Do not reuse the key with different parameters: if the cart total changes, Stripe answers with an idempotency error, so update the existing PaymentIntent instead of creating another.

The atomic lock uses Laravel's cache locks, which need a shared lock-capable store (Redis, database or Memcached) across all web servers. block(5, ...) waits up to five seconds for a concurrent request to finish and then reads the stored id rather than creating a second one.

Razorpay

PHP
<?php

namespace App\Payments;

use App\Models\Order;
use Illuminate\Support\Facades\Cache;
use Razorpay\Api\Api;

final class RazorpayCheckout
{
    public function __construct(private Api $api) {}

    /** Returns the Razorpay order id to pass to Checkout; one Razorpay order per local order. */
    public function razorpayOrderFor(Order $order): string
    {
        return Cache::lock("checkout:{$order->id}", 15)->block(5, function () use ($order) {
            $order->refresh();

            if ($order->provider_order_id !== null) {
                return $order->provider_order_id;   // reuse: never create a second Razorpay order
            }

            $rzpOrder = $this->api->order->create([
                'amount' => $order->amount,          // paise
                'currency' => $order->currency,
                'receipt' => $order->public_id,      // max 40 characters
                'notes' => ['order_id' => $order->public_id],
            ]);

            $order->update(['provider' => 'razorpay', 'provider_order_id' => $rzpOrder['id']]);

            return $rzpOrder['id'];
        });
    }
}

Razorpay recommends creating an order for every payment and passing its id to Checkout. Because there is no idempotency header for Orders, the lock and the stored provider_order_id are what prevent a second Razorpay order. Put your own order id in receipt (at most 40 characters) so the two can be matched in the Dashboard and during reconciliation. Razorpay's model allows several payment attempts against one order, so a customer retrying after a failed attempt should be sent back to the same order id, which keeps reconciliation simple.

Standing sign with a large BHIM UPI QR code and Indian Bank branding accepting entrance fees at Shaurya Smarak in Bhopal
Razorpay accepts UPI as well as cards, and the order.paid webhook confirms either kind of payment the same way.

After Checkout succeeds, the browser receives razorpay_payment_id, razorpay_order_id and razorpay_signature. Verify them on the server with $api->utility->verifyPaymentSignature([...]), using the order id from your database rather than the one the browser sent, and use the result to show a confirmation page. Treat the order.paid webhook as the event that marks the order paid, so a closed browser tab never loses a payment.

Webhooks: verify, store once, acknowledge

Webhook pipeline diagram: verify signature, insert ignore into webhook_events, return 200, queued job with row lock, conditional order update, fulfil once, scheduled reconcile
Every step checks state before acting, so provider retries and queue retries are harmless.

The webhook controller does three things and nothing else: verify the signature on the raw body, insert the event into webhook_events with insertOrIgnore, and return 200. Only a newly inserted row dispatches a job. A duplicate delivery hits the unique index, inserts nothing, and still gets a 200 so the provider stops retrying.

PHP
<?php

namespace App\Http\Controllers\Webhooks;

use App\Jobs\ProcessWebhookEvent;
use App\Models\WebhookEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
use UnexpectedValueException;

class StripeWebhookController
{
    public function __invoke(Request $request): Response
    {
        try {
            $event = Webhook::constructEvent(
                $request->getContent(),                      // raw body, never the parsed array
                (string) $request->header('Stripe-Signature'),
                (string) config('services.stripe.webhook_secret'),
            );                                               // default tolerance: 300 seconds
        } catch (UnexpectedValueException|SignatureVerificationException) {
            return response('Invalid payload or signature', 400);
        }

        return $this->store('stripe', $event->id, $event->type, $request->getContent());
    }

    protected function store(string $provider, string $eventId, string $type, string $raw): Response
    {
        $inserted = WebhookEvent::query()->insertOrIgnore([
            'provider' => $provider,
            'event_id' => $eventId,
            'type' => $type,
            'payload' => $raw,
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        if ($inserted === 1) {
            $id = WebhookEvent::where(['provider' => $provider, 'event_id' => $eventId])->value('id');
            ProcessWebhookEvent::dispatch($id);
        }

        return response('', 200);                            // duplicates are acknowledged too
    }
}

Razorpay differs only in how the signature and event id arrive. The raw body must be passed unchanged; Razorpay's documentation explicitly warns against parsing or casting it before validation.

PHP
<?php

namespace App\Http\Controllers\Webhooks;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Razorpay\Api\Errors\SignatureVerificationError;
use Razorpay\Api\Utility;

class RazorpayWebhookController extends StripeWebhookController
{
    public function __invoke(Request $request): Response
    {
        $raw = $request->getContent();
        $eventId = (string) $request->header('X-Razorpay-Event-Id');

        try {
            (new Utility)->verifyWebhookSignature(
                $raw,
                (string) $request->header('X-Razorpay-Signature'),
                (string) config('services.razorpay.webhook_secret'),
            );
        } catch (SignatureVerificationError) {
            return response('Invalid signature', 400);
        }

        $type = (string) (json_decode($raw, true)['event'] ?? '');
        if ($eventId === '' || $type === '') {
            return response('Missing event id or type', 400);
        }

        return $this->store('razorpay', $eventId, $type, $raw);
    }
}

Register the routes and exclude them from CSRF protection, since providers cannot send a CSRF token. In Laravel 13 the method is preventRequestForgery; Laravel 12 uses validateCsrfTokens with the same except argument.

PHP
// routes/web.php
Route::post('/webhooks/stripe', StripeWebhookController::class);
Route::post('/webhooks/razorpay', RazorpayWebhookController::class);

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->preventRequestForgery(except: ['webhooks/*']);
})

Never read the payload through $request->all() or a FormRequest before verification: anything that re-encodes JSON changes the bytes and breaks the signature. $request->getContent() returns the body exactly as received.

Queued processing that is safe to retry

The job locks the event row, skips it if it was already processed, and moves the order from pending to paid with a conditional update. Only the caller that actually changed the row dispatches fulfilment. Fulfilment then claims fulfilled_at the same way, so even a duplicated or retried fulfilment job sends one email and grants access once.

PHP
<?php

namespace App\Jobs;

use App\Models\Order;
use App\Models\WebhookEvent;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Throwable;

class ProcessWebhookEvent implements ShouldQueue
{
    use Queueable;

    public int $tries = 8;

    public function __construct(public int $webhookEventId) {}

    /** Exponential-ish backoff between attempts, in seconds. */
    public function backoff(): array
    {
        return [10, 30, 60, 300, 900, 1800, 3600];
    }

    public function handle(): void
    {
        DB::transaction(function () {
            // Row lock: a second worker with the same event waits, then sees processed_at.
            $event = WebhookEvent::whereKey($this->webhookEventId)->lockForUpdate()->firstOrFail();
            if ($event->processed_at !== null) {
                return;
            }

            $event->increment('attempts');
            $this->apply($event);
            $event->forceFill(['processed_at' => now(), 'last_error' => null])->save();
        });
    }

    private function apply(WebhookEvent $event): void
    {
        $p = $event->payload;

        [$providerOrderId, $paid] = match ([$event->provider, $event->type]) {
            ['stripe', 'payment_intent.succeeded'] => [$p['data']['object']['id'], true],
            ['razorpay', 'order.paid'] => [$p['payload']['order']['entity']['id'], true],
            default => [null, false],                        // ignore types we do not handle
        };

        if (! $paid) {
            return;
        }

        // Conditional update: only one caller can move pending -> paid.
        $changed = Order::where('provider_order_id', $providerOrderId)
            ->where('status', 'pending')
            ->update(['status' => 'paid', 'paid_at' => now()]);

        if ($changed === 1) {
            FulfilOrder::dispatch($providerOrderId)->afterCommit();
        }
    }

    public function failed(?Throwable $e): void
    {
        WebhookEvent::whereKey($this->webhookEventId)->update(['last_error' => $e?->getMessage()]);
    }
}
PHP
<?php

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class FulfilOrder implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $providerOrderId) {}

    public function handle(): void
    {
        // Claim fulfilment exactly once, even if this job is retried or dispatched twice.
        $claimed = Order::where('provider_order_id', $this->providerOrderId)
            ->where('status', 'paid')
            ->whereNull('fulfilled_at')
            ->update(['fulfilled_at' => now()]);

        if ($claimed === 0) {
            return;
        }

        // Send the email, grant access, create the shipment ... (each step idempotent too)
    }
}

A few production details:

  • afterCommit() makes sure fulfilment is only queued once the paid status is committed; otherwise a fast worker can read the old status.
  • If an exception is thrown, the transaction rolls back, processed_at stays null, and the queue retries with the backoff above. Make the side effects in FulfilOrder idempotent too: send emails through a table of sent notifications, and pass idempotency keys to any API you call.
  • Because events arrive out of order, never assume payment_intent.created comes before payment_intent.succeeded. The conditional update on status makes order of arrival irrelevant for the paid transition.
  • Run these jobs on a queue with monitoring; our Laravel Horizon setup guide covers alerting on failed jobs.

Reconciliation: catch what the pipeline missed

Webhooks can be delayed, an endpoint can be disabled after a day of failures, and a worker can be down during a deploy. A scheduled command closes the gap: it re-queues stored events that were never processed, and asks the provider about orders that have been pending too long. When the provider says an order is paid, it records a synthetic event and lets the normal idempotent path do the work, so there is still only one code path that marks orders paid.

PHP
<?php

namespace App\Console\Commands;

use App\Jobs\ProcessWebhookEvent;
use App\Models\Order;
use App\Models\WebhookEvent;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Razorpay\Api\Api;
use Stripe\StripeClient;

class ReconcilePayments extends Command
{
    protected $signature = 'payments:reconcile';

    protected $description = 'Re-queue stuck webhook events and check pending orders with the provider';

    public function handle(StripeClient $stripe, Api $razorpay): int
    {
        // 1. Stored but never processed (worker down, deploy, bug fixed since).
        WebhookEvent::whereNull('processed_at')
            ->where('created_at', '<', now()->subMinutes(15))
            ->pluck('id')
            ->each(fn (int $id) => ProcessWebhookEvent::dispatch($id));

        // 2. Pending orders older than 30 minutes: ask the provider directly.
        Order::where('status', 'pending')
            ->whereNotNull('provider_order_id')
            ->where('created_at', '<', now()->subMinutes(30))
            ->where('created_at', '>', now()->subDays(3))
            ->each(function (Order $order) use ($stripe, $razorpay) {
                $paid = match ($order->provider) {
                    'stripe' => $stripe->paymentIntents->retrieve($order->provider_order_id)->status === 'succeeded',
                    'razorpay' => $razorpay->order->fetch($order->provider_order_id)['status'] === 'paid',
                    default => false,
                };

                if ($paid) {
                    $this->warn("Order {$order->public_id} is paid at the provider but pending here");
                    // Record a synthetic event so the normal, idempotent path does the work.
                    DB::table('webhook_events')->insertOrIgnore([
                        'provider' => $order->provider,
                        'event_id' => 'reconcile-'.$order->provider_order_id,
                        'type' => $order->provider === 'stripe' ? 'payment_intent.succeeded' : 'order.paid',
                        'payload' => json_encode($order->provider === 'stripe'
                            ? ['data' => ['object' => ['id' => $order->provider_order_id]]]
                            : ['payload' => ['order' => ['entity' => ['id' => $order->provider_order_id]]]]),
                        'created_at' => now()->subMinutes(16),
                        'updated_at' => now(),
                    ]);
                }
            });

        return self::SUCCESS;
    }
}
PHP
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('payments:reconcile')->everyFifteenMinutes()->withoutOverlapping();

Also reconcile money, not just statuses: once a day, compare captured payments and refunds reported by the provider (Stripe balance transactions, Razorpay settlements) with your orders table and alert on any difference. If your logs already show orders paid twice or paid but never fulfilled, a payment integration review that traces each case through the provider dashboard and your database will find which of these layers is missing.

Testing the guarantees

Write feature tests that send the same signed webhook several times and assert one stored event, one status change and one fulfilment, plus a test with a bad signature that stores nothing. Stripe's signature is easy to build in a test: hash_hmac('sha256', "$timestamp.$payload", $secret) in a t=...,v1=... header. Razorpay's is hash_hmac('sha256', $payload, $secret). For end-to-end checks, stripe listen --forward-to and stripe trigger payment_intent.succeeded deliver real test events, and the Stripe Dashboard and CLI can resend an event to prove the duplicate path.

Common mistakes

  • Generating a fresh UUID as the idempotency key on every request, which protects nothing against double clicks.
  • Fulfilling from the browser redirect or the Razorpay handler callback instead of the webhook.
  • Checking "already processed?" with a SELECT and then inserting, without a unique index; two concurrent deliveries both pass the check.
  • Doing the work inside the webhook request, so slow email or PDF generation causes timeouts and redeliveries.
  • Returning 4xx or 5xx for a duplicate event, which makes the provider retry it for days.
  • Verifying signatures against a re-encoded body, or setting the Stripe tolerance to 0, which disables the timestamp check.
  • Using a cache driver that is local to each server (file, array) for locks in a multi-server deployment.

When this is more than you need

If you sell subscriptions through Laravel Cashier and only react to a handful of events, Cashier's built-in webhook controller and its signature verification may be enough; add the webhook_events table only where you run your own side effects. If payment volume is a few orders a week and fulfilment is manual, a daily reconciliation against the provider dashboard may be the cheaper control. And if you use hosted payment links with no fulfilment logic in your app, there is nothing to deduplicate. The layers above earn their keep once fulfilment is automatic: access, licences, stock, or anything that costs money when done twice.

Cloudsoft Zone builds and reviews Stripe and Razorpay integrations in Laravel so that each order is charged once and fulfilled once: idempotent checkout, verified webhooks, retry-safe jobs and daily reconciliation. See payment and third-party integrations.

Frequently asked questions

  • Does Razorpay support idempotency keys for orders?

    No. Razorpay documents idempotency only for the Payouts and composite APIs via X-Payout-Idempotency. For orders, create one Razorpay order per local order under a lock, store its id in a unique column and reuse it.

  • Is stripe-php's automatic idempotency key enough?

    It protects one API call against network retries when max_network_retries is set, because the library adds a random key to that request. A double click or a retried job makes a new call with a new random key, so derive your own key from the order.

  • Should a duplicate webhook return an error?

    No. Return 200 for events you have already stored. An error makes Stripe retry for up to three days and Razorpay for 24 hours, after which Razorpay disables the webhook.

  • Why store webhook events in a table instead of processing them directly?

    The unique index on provider and event id is the deduplication guarantee, the stored payload lets you reprocess after a bug fix, and responding quickly keeps you inside Razorpay's 5-second limit and Stripe's timeout.

  • How do I test duplicate webhook handling in Laravel?

    Post the same signed payload several times in a feature test and assert one row in webhook_events, one paid status and one fulfilment. Build Stripe signatures as HMAC-SHA256 of timestamp.payload and Razorpay signatures as HMAC-SHA256 of the payload.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Idempotent requests — Stripe
  2. Receive Stripe events in your webhook endpoint — Stripe
  3. Validate and test webhooks — Razorpay
  4. Webhook best practices — Razorpay
  5. Payout idempotency — Razorpay
  6. Standard Checkout integration steps (orders and payment signature) — Razorpay
  7. Laravel 13 cache: atomic locks — Laravel
  8. Laravel 13 CSRF protection: excluding URIs — Laravel
  9. stripe-php library — Stripe on GitHub, 2026-09-09

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.

  • Plush elePHPant toy from Heroku, a version of the PHP community mascot, photographed close up

    Laravel

    Upgrading from Laravel 12 to Laravel 13: a practical guide

    Laravel 12 lost bug-fix support in August 2026. This guide covers the PHP 8.3 requirement, Composer changes, the CSRF middleware rename, cache and session hardening, low-impact changes that often bite, and a safe rollout plan.

    By Cloudsoft Zone

  • Blue plush elephant toy, the PHP community mascot known as the elePHPant

    ComparisonLaravel

    Laravel Sanctum vs Passport: which API authentication package to use

    Sanctum and Passport both authenticate Laravel APIs, but they solve different problems. This comparison explains tokens, SPA cookies, OAuth2 grants, expiry and clean-up in Laravel 13, and when each package is the right choice.

    By Cloudsoft Zone