Payment Webhooks in Laravel: Signature Verification, Retries, and Replay-Safe Processing#

A customer completes a payment. Your checkout page waits for the redirect to show a success message, but the redirect never arrives — the customer closed the tab the moment their bank approved the charge. Meanwhile, the gateway already sent the one message that actually matters: a webhook saying payment_intent.succeeded. If that webhook is verified, queued, and processed correctly, the order completes and the receipt goes out. If your endpoint mishandles it, you have a paid customer and an unpaid order.

Webhooks are the backbone of payment integrations, and they are also where subtle production bugs live: unverified payloads, duplicate deliveries, out-of-order events, and endpoints that time out because they did heavy work before responding. This article builds a payment webhook endpoint in Laravel that handles all four problems, with code you can adapt to Stripe, Adyen, Paymob, or any gateway that signs its webhooks.

Why webhooks, not redirects#

Redirects and return URLs are a user-experience feature. They tell the human in front of the browser what happened — most of the time. They are not a reliable delivery mechanism: tabs close, networks drop, ad blockers interfere, mobile browsers kill backgrounded pages.

Payment gateways know this, which is why every serious gateway also sends a server-to-server webhook for every meaningful state change: payment authorized, captured, failed, refunded, chargeback opened. The gateway keeps retrying until your endpoint acknowledges. Your database should treat the webhook stream as the source of truth for payment state, and the redirect as decoration.

That design decision has a consequence: your webhook endpoint is now part of your money path. It deserves the same rigor as the charge request itself.

How gateway webhooks actually behave#

Three properties of real webhook delivery drive every design decision in this article:

1. Delivery is at-least-once. If your endpoint responds with anything other than a 2xx — or responds too slowly — the gateway retries. Stripe retries failed deliveries for up to three days with exponential backoff. Adyen retries according to its own schedule. Even on success, network ambiguity can produce a duplicate: your server processed the event, but the gateway never saw the acknowledgment, so it sends the event again. Duplicates are normal traffic, not an edge case.

2. Events can arrive out of order. charge.refunded can reach you before charge.succeeded in rare cases, especially when one delivery was retried and the other was not. Any code that assumes strict ordering will eventually corrupt state.

3. Anyone can POST to a public URL. Your webhook endpoint is reachable from the open internet. Without signature verification, a forged POST that says "order #123 is paid" is indistinguishable from a real one.

How gateways sign webhooks#

All major gateways solve authenticity the same way: they sign the exact request body with a secret only the two of you hold, and put the signature in a header. Your job is to recompute the signature over the raw, unparsed request body and compare in constant time.

Gateway / standard Signature header(s) Scheme Replay protection
Stripe Stripe-Signature: t=<ts>,v1=<sig> HMAC-SHA256 over t.raw_body with endpoint secret Timestamp in signature; you reject old timestamps (Stripe recommends a 5-minute tolerance)
Adyen hmacSignature inside each notification item HMAC-SHA256 over a defined concatenation of item fields You validate per item
Standard Webhooks (spec) webhook-id, webhook-timestamp, webhook-signature HMAC-SHA256 over id.timestamp.body, base64 Timestamp tolerance, typically 5 minutes
Paymob hmac query parameter HMAC-SHA512 over concatenated transaction fields None built in — add your own checks

Two details matter more than any other:

  • Sign the raw body. If you verify a signature computed over json_encode($request->all()), it will fail intermittently — whitespace, key order, and unicode escaping change the bytes. Always use $request->getContent() in Laravel.
  • Compare with hash_equals(). A == comparison leaks timing information about how many prefix characters matched. hash_equals is constant-time.

The shape of a correct endpoint#

Before the code, the flow. A production-grade webhook endpoint does almost nothing synchronously:

Diagram of a payment webhook pipeline: receive, verify signature, queue for durable acceptance, acknowledge fast, then process with deduplication and guarded state transitions

Every stage has one job, and the order matters:

  1. Verify — signature and timestamp, or reject cheaply.
  2. Queue — hand the payload to a queued job. This is the durable acceptance step: the event is safe before you say so.
  3. Acknowledge fast — return 2xx within the gateway's timeout (typically 10–30 seconds; aim for well under one second), and only after the payload is queued. If enqueueing fails, do not ack — answer 5xx and let the gateway's retry schedule cover you.
  4. Process replay-safely — deduplicate by event id and apply state transitions that tolerate duplicates and reordering.

Building it in Laravel#

The examples use Stripe's header format, but each step notes what changes for other gateways.

Step 1: The processed events table#

Deduplication needs a unique database constraint — application-level "check then insert" races under concurrency, the constraint does not:

PHP
Schema::create('processed_gateway_events', function (Blueprint $table) {
    $table->id();
    $table->string('provider', 32);            // stripe, adyen, paymob...
    $table->string('event_id');                // the gateway's unique event id
    $table->json('payload');
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();

    $table->unique(['provider', 'event_id']);
});

Step 2: Signature verification middleware#

PHP
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyStripeSignature
{
    private const TOLERANCE_SECONDS = 300; // provider-specific; Stripe recommends 5 minutes

    public function handle(Request $request, Closure $next): Response
    {
        $header = $request->header('Stripe-Signature', '');
        $secret = config('services.stripe.webhook_secret');

        if (! preg_match('/t=(?<t>\d+),.*v1=(?<v1>[a-f0-9]+)/', $header, $m)) {
            return response()->json(['message' => 'Malformed signature header'], 400);
        }

        // `t` is the gateway-signed delivery timestamp from the header -
        // not the event's `created` field, which lags on retries.
        if (abs(time() - (int) $m['t']) > self::TOLERANCE_SECONDS) {
            return response()->json(['message' => 'Stale timestamp'], 400);
        }

        $expected = hash_hmac('sha256', $m['t'] . '.' . $request->getContent(), $secret);

        if (! hash_equals($expected, $m['v1'])) {
            return response()->json(['message' => 'Invalid signature'], 401);
        }

        return $next($request);
    }
}

Register it on the webhook route group. On Laravel 11 and 12, alias it in bootstrap/app.php; on Laravel 10 and below, in app/Http/Kernel.php. One nuance: a single Stripe-Signature header can carry several v1 values during secret rotation, so production code should accept the header if any v1 matches — Stripe's own SDK does exactly this via Webhook::constructEvent, and using the official SDK is a perfectly good choice here.

What changes per gateway: for Adyen, verify the per-item hmacSignature against your HMAC key; for Paymob, concatenate the documented fields and verify with HMAC-SHA512. The middleware shape stays the same.

Step 3: The controller — verify, enqueue, acknowledge#

PHP
namespace App\Http\Controllers\Webhooks;

use App\Http\Controllers\Controller;
use App\Jobs\ProcessStripeEvent;
use Illuminate\Http\Request;

class StripeWebhookController extends Controller
{
    public function __invoke(Request $request)
    {
        $event = json_decode($request->getContent(), true);

        try {
            ProcessStripeEvent::dispatch(
                eventId: $event['id'],
                type: $event['type'],
                payload: $request->getContent(),
            );
        } catch (\Throwable $e) {
            report($e);
            // No durable acceptance, no 200. A 5xx puts the event
            // back on the gateway's retry schedule.
            return response()->json(['message' => 'temporarily unavailable'], 503);
        }

        return response()->json(['received' => true]);
    }
}

Note what this controller does not do: touch orders, send email, call the gateway back, or run any query heavier than a dispatch. If anything in processing throws, the queue — not the gateway — handles the retry, and the gateway still got its 200. If the dispatch itself fails (broker unreachable), the 503 means no event is silently lost. How durable "queued" is depends on the driver: a Redis queue survives requests but not necessarily a Redis wipe, so for payment events a database queue connection (or Redis with AOF persistence) is the stronger default.

The route:

PHP
Route::post('/webhooks/stripe', StripeWebhookController::class)
    ->middleware(VerifyStripeSignature::class)
    ->name('webhooks.stripe');

Exclude this route from CSRF verification (server-to-server POSTs carry no token) and keep it out of any auth middleware — the signature is the authentication.

Step 4: The job — deduplicate, then transition state#

PHP
namespace App\Jobs;

use App\Models\Payment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;

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

    public int $tries = 5;

    /** @var int[] seconds between attempts */
    public array $backoff = [30, 60, 300, 900];

    public function __construct(
        public string $eventId,
        public string $type,
        public string $payload,
    ) {}

    public function retryUntil(): \DateTime
    {
        // Outlast the gateway's own retry window.
        return now()->addHours(26);
    }

    public function handle(): void
    {
        $event = json_decode($this->payload, true);

        try {
            DB::transaction(function () use ($event) {
                // The marker and the state transition commit together or not
                // at all: a crash mid-job rolls back the marker too, so the
                // retried delivery gets to process the event again.
                DB::table('processed_gateway_events')->insert([
                    'provider'   => 'stripe',
                    'event_id'   => $this->eventId,
                    'payload'    => $this->payload,
                    'created_at' => now(),
                    'updated_at' => now(),
                ]);

                match ($this->type) {
                    'payment_intent.succeeded' => $this->markPaid($event['data']['object']),
                    'payment_intent.payment_failed' => $this->markFailed($event['data']['object']),
                    default => null, // subscribe narrowly; ignore the rest deliberately
                };

                DB::table('processed_gateway_events')
                    ->where('provider', 'stripe')
                    ->where('event_id', $this->eventId)
                    ->update(['processed_at' => now()]);
            });
        } catch (UniqueConstraintViolationException) {
            return; // a concurrent or earlier worker owns this event
        }
    }

    private function markPaid(array $intent): void
    {
        // paid is forward-only: it may later become refunded or disputed,
        // but a late-arriving succeeded must never resurrect it.
        Payment::where('gateway_reference', $intent['id'])
            ->whereNotIn('status', ['paid', 'refunded', 'disputed'])
            ->update(['status' => 'paid', 'paid_at' => now()]);
    }

    private function markFailed(array $intent): void
    {
        Payment::where('gateway_reference', $intent['id'])
            ->where('status', 'pending')
            ->update(['status' => 'failed']);
    }
}

Three details carry the reliability:

  • The unique insert is the dedup mechanism, and it lives inside the transaction. Whichever worker commits first owns the event; every duplicate — whether from gateway retries, a redelivered test event, or a queue redelivery — dies quietly on the constraint. Wrapping marker and transition in one transaction closes the nastier hole: without it, a job that crashes after the insert but before the transition leaves a marker that makes the next delivery exit quietly, and the event is lost forever. This is the same "verify once, process once" discipline as idempotency keys on your own API, applied to inbound events (the request-side version is covered here).
  • State transitions guard against reordering, and "terminal" needs precision. Pending can become paid or failed; paid is forward-only — it can still become refunded or disputed, just never pending or failed again; refunded and disputed are terminal. The whereNotIn guards encode exactly that, so a late-arriving succeeded can never resurrect a refunded payment and a late failed can never demote a paid one. If your flows are more complex, model payment status as an explicit state machine instead of growing these where clauses.
  • retryUntil outlives the gateway's schedule. If your bug means every attempt fails, the job stays alive long enough for you to deploy a fix before either system gives up.
Flowchart of replay-safe webhook processing decisions: verify signature, check delivery timestamp, queue before acknowledging, deduplicate by event id, then apply a guarded state transition in one transaction

Step 5: Testing it#

Local development needs a tunnel so the gateway can reach your machine. Stripe's CLI does this and, just as usefully, replays fixed events:

Bash
stripe listen --forward-to https://your-app.test/webhooks/stripe
stripe trigger payment_intent.succeeded
stripe events resend evt_123   # force a duplicate on demand

That last command is the important one: replay an event and assert nothing changes twice.

In PHPUnit, sign the payload exactly like the gateway would:

PHP
public function test_valid_signature_dispatches_processing_job(): void
{
    Queue::fake();

    $payload = json_encode(['id' => 'evt_test_1', 'type' => 'payment_intent.succeeded', 'data' => ['object' => []]]);
    $t = time();
    $secret = config('services.stripe.webhook_secret');
    $sig = hash_hmac('sha256', "$t.$payload", $secret);

    $this->postJson('/webhooks/stripe', json_decode($payload, true), [
        'Stripe-Signature' => "t=$t,v1=$sig",
    ])->assertOk();

    Queue::assertPushed(ProcessStripeEvent::class);
}

public function test_forged_signature_is_rejected(): void
{
    Queue::fake();

    $this->postJson('/webhooks/stripe', [], [
        'Stripe-Signature' => 't=' . time() . ',v1=' . str_repeat('0', 64),
    ])->assertUnauthorized();

    Queue::assertNothingPushed();
}

public function test_duplicate_event_is_processed_once(): void
{
    // Dispatch the same event id twice; assert one payment transition.
}

Careful with postJson in tests: it re-encodes your payload. For byte-exact signature testing, use call('POST', $uri, ..., content: $payload) so the bytes you sign are the bytes Laravel receives.

Common mistakes#

  • Verifying a signature over re-serialized JSON. Works in your tests, fails against the real gateway at 2 a.m. Sign and verify raw bytes only.
  • Doing the work before the 200. Sending receipts or provisioning accounts inside the controller stretches response time until the gateway marks the delivery failed and retries — you just manufactured duplicates.
  • Treating duplicates as errors. Returning non-2xx for an already-processed event tells the gateway to keep retrying a delivery you already handled. Acknowledge and move on. The mirror image also deserves care: a 4xx on an invalid signature signals "stop," but gateways are not obliged to honor it — Stripe retries any non-2xx for a while. That is fine: verification is cheap and idempotent, so retried forgeries just get rejected again.
  • Assuming event order. created timestamps inside payloads help humans read logs; they are not a sequencing primitive. Guard state transitions instead.
  • Ignoring failed jobs. A webhook job that exhausts its retries lands in failed_jobs silently. Alert on it (Laravel fires a JobFailed event you can hook) — a failed webhook job is a payment state nobody is updating.
  • No secret rotation plan. Webhook secrets should rotate like any credential. Support overlapping secrets (accept any v1 match) so rotation never drops events.

Security and operations notes#

  • The endpoint is unauthenticated by design — the signature is the credential, so protect webhook secrets like API keys: env only, never committed, rotated on suspicion.
  • Log every delivery (event id, type, signature valid/invalid) before processing. When a payment state looks wrong three months later, that log is the difference between a ten-minute audit and a forensic project.
  • Rate limit the route defensively; a verified endpoint is still a public write path.
  • Monitor two queues, not one: your Laravel queue depth, and the gateway's own failed-deliveries dashboard. They fail in different ways.

Production checklist#

  • Signature verified against the raw request body, constant-time comparison
  • Timestamp tolerance enforced (replay window closed)
  • 2xx returned only after the payload is durably queued; enqueue failure answers 5xx so the gateway retries
  • Unique constraint on (provider, event_id); marker and state transition commit in one transaction; duplicates exit quietly
  • State transitions guarded against out-of-order events
  • retryUntil longer than the gateway's retry window; failed_jobs alerting in place
  • CSRF excluded, route rate-limited, deliveries logged
  • Duplicate replay tested (stripe events resend or equivalent)
  • Secret rotation procedure documented

Key takeaways#

  • Webhooks are the source of truth for payment state; redirects are UX.
  • At-least-once delivery means duplicates and reordering are guaranteed over time — design for them, don't hope around them.
  • Verify signatures on raw bytes, hand the payload to the queue before you ack, deduplicate marker and state transition in one transaction, and make every transition safe to apply twice.
  • The endpoint that never breaks is boring by design: a middleware, a one-line controller, a careful job.

References and further reading#

If you are wiring payments into a Laravel platform and want a second pair of eyes on the webhook and reconciliation layer, that is exactly the kind of work I do — feel free to reach out.