Payment Webhooks in Laravel: Signature Verification, Retries, and Replay-Safe Processing
Build a payment webhook endpoint in Laravel that verifies HMAC signatures, acks fast, survives retries and duplicates, and processes events safely with queues and MySQL.
Table of contents
- Payment Webhooks in Laravel: Signature Verification, Retries, and Replay-Safe Processing
- Why webhooks, not redirects
- How gateway webhooks actually behave
- How gateways sign webhooks
- The shape of a correct endpoint
- Building it in Laravel
- Step 1: The processed events table
- Step 2: Signature verification middleware
- Step 3: The controller — verify, enqueue, acknowledge
- Step 4: The job — deduplicate, then transition state
- Step 5: Testing it
- Common mistakes
- Security and operations notes
- Production checklist
- Key takeaways
- References and further reading
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_equalsis constant-time.
The shape of a correct endpoint#
Before the code, the flow. A production-grade webhook endpoint does almost nothing synchronously:

Every stage has one job, and the order matters:
- Verify — signature and timestamp, or reject cheaply.
- Queue — hand the payload to a queued job. This is the durable acceptance step: the event is safe before you say so.
- 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.
- 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:
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#
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#
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:
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#
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
whereNotInguards encode exactly that, so a late-arrivingsucceededcan never resurrect a refunded payment and a latefailedcan never demote a paid one. If your flows are more complex, model payment status as an explicit state machine instead of growing thesewhereclauses. retryUntiloutlives 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.

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:
stripe listen --forward-to https://your-app.test/webhooks/stripe
stripe trigger payment_intent.succeeded
stripe events resend evt_123 # force a duplicate on demandThat last command is the important one: replay an event and assert nothing changes twice.
In PHPUnit, sign the payload exactly like the gateway would:
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.
createdtimestamps 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_jobssilently. Alert on it (Laravel fires aJobFailedevent 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
v1match) 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
-
retryUntillonger than the gateway's retry window;failed_jobsalerting in place - CSRF excluded, route rate-limited, deliveries logged
- Duplicate replay tested (
stripe events resendor 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#
- Stripe — Receive events in your webhook endpoint and managing webhook endpoints
- Adyen — Verify HMAC signatures
- Standard Webhooks specification — the emerging cross-vendor signing standard
- Laravel — Queues (backoff, retryUntil, failed job handling)
- Idempotency Keys in Payment APIs: How to Stop Double Charges in Laravel — the outbound-request companion to this article
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.