Signature verification, idempotency and ordered processing: the three problems every developer hits the second they go to production.

The order was created twice. Same customer, same amount, two separate records in the database. The support ticket came in six hours later: “I was charged twice.”
The code looked fine. The handler checked the payload type, called the order creation logic, returned 200. What it did not do was check whether it had already processed that event. Stripe had retried the delivery after a brief timeout, the second attempt landed on a different worker, and both ran to completion. That is not a Stripe bug. Stripe retries for up to three days in live mode because it cannot know whether your server processed the event or just failed to respond.
The problem is not the retry. The problem is a receiver that is not ready for it.
This article is a production blueprint for a webhook receiver in Laravel that handles three things: verifying the sender is who they claim to be, processing each event exactly once regardless of how many times it arrives, and dealing with ordering constraints without turning your queue into a state machine. The examples use Stripe-style webhooks because that is the most concrete, widely-used case: but the same architecture applies to GitHub, Shopify, Twilio or any provider that signs payloads.
What This System Needs to Do
Before writing any code, it helps to be precise about the requirements. Vague requirements produce systems that handle the happy path and fall apart everywhere else.
Functional requirements:
- Accept a POST payload and return a 200 response in under 200 milliseconds
- Verify the sender’s identity using the signature in the request headers
- Reject payloads that fail signature verification or arrive outside the replay window
- Process each event exactly once, even if the same event arrives multiple times
- No events lost on processing failure
Non-functional requirements:
- Handle bursts up to 10,000 events per hour (~2.8 per second sustained; assume 5x peaks)
- Keep the HTTP endpoint stateless so it scales horizontally
- No more than 10ms of added latency for the signature check and dedup lookup
- Survive a queue worker restart without reprocessing or losing events
What this system does not do:
- Guarantee strict ordering across all event types (covered separately in the ordering section)
- Internal fan-out to multiple consumers: that is a pub/sub problem, not a webhook receiver problem
The Architecture in Plain Terms
The core design is a two-layer split: a thin HTTP layer that accepts fast and verifies immediately, and a processing layer that runs asynchronously in the background.
The HTTP endpoint does three things only:
- Verify the HMAC signature against the request headers and body
- Check whether this event ID has already been seen (dedup gate)
- Persist the raw payload and dispatch a queue job, then return 200
Everything else: parsing the payload, calling your business logic, updating database records: happens in the queue worker. The HTTP endpoint never touches application logic. It is a door with a lock.
Why return 200 before processing? Because Stripe measures success by whether your endpoint responds with a 2xx status within its delivery window, and that window is shorter than the time it takes to provision a subscription, run a compliance check or send a confirmation email. If the endpoint times out, Stripe retries. The retry hits your dedup layer. The original event is still in the queue, processing correctly. Returning early is not cutting a corner. It is the design that every team lands on after their first production timeout.
Here is the high-level flow:
POST /webhooks/stripe
→ VerifySignatureMiddleware (reject if invalid or expired)
→ WebhookController@receive
→ Dedup check (Redis SET NX)
→ If new: persist raw payload + dispatch ProcessWebhookJob
→ Return 200
→ ProcessWebhookJob (queue worker)
→ Parse event type
→ Route to handler
→ Update application stateLayer One: Verifying the Signature
This is the first thing that breaks when people skip it: someone discovers your webhook endpoint and starts posting fake payloads. An unsigned POST to /webhooks/stripe with a crafted payment_intent.succeeded body could trigger order fulfillment without a real payment.
Stripe signs every request with HMAC-SHA256. The Stripe-Signature header looks like this:
t=1714662000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdThe t field is the Unix timestamp. The v1 field is the HMAC-SHA256 signature of the string {timestamp}.{raw_body} using your webhook signing secret. Stripe puts the timestamp inside the signature so that old signatures cannot be replayed: any signature older than 5 minutes should be rejected.
There is one critical detail in the implementation: do not use === to compare signatures. PHP's === is not constant-time. It exits early on the first differing byte, which means an attacker can measure response times to reverse-engineer the expected signature one character at a time. Use hash_equals() instead. Laravel already uses this internally in its CSRF verification, which is the correct signal that it should be used everywhere you compare secrets.
Here is a Laravel middleware that handles the full verification:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyStripeWebhookSignature
{
private const TOLERANCE_SECONDS = 300; // 5 minutes
public function handle(Request $request, Closure $next): Response
{
$sigHeader = $request->header('Stripe-Signature');
if (empty($sigHeader)) {
return response('Missing signature', 400);
}
$parts = [];
foreach (explode(',', $sigHeader) as $part) {
[$key, $value] = explode('=', $part, 2);
$parts[$key] = $value;
}
if (empty($parts['t']) || empty($parts['v1'])) {
return response('Malformed signature header', 400);
}
$timestamp = (int) $parts['t'];
$now = time();
if (abs($now - $timestamp) > self::TOLERANCE_SECONDS) {
return response('Expired signature', 400);
}
// Stripe signs the string "{timestamp}.{raw_body}"
$rawBody = $request->getContent();
$signedPayload = $timestamp . '.' . $rawBody;
$expectedSig = hash_hmac('sha256', $signedPayload, config('services.stripe.webhook_secret'));
if (! hash_equals($expectedSig, $parts['v1'])) {
return response('Invalid signature', 403);
}
return $next($request);
}
}Register this middleware on your webhook route only, not globally:
Route::post('/webhooks/stripe', [WebhookController::class, 'receive'])
->middleware(VerifyStripeWebhookSignature::class)
->name('webhooks.stripe');One thing worth getting right before the next layer: the raw body. Some frameworks parse the request body before the middleware runs and then re-serialize it, which can alter whitespace or key ordering. Laravel’s $request->getContent() returns the raw bytes as received, which is what Stripe signed. If you use body-parsing middleware upstream from this route, you need to ensure it does not modify the raw body stream for this path.
Layer Two: Deduplication
The middleware confirmed the sender. Now the question is: have you seen this event before?
Stripe retries delivery for up to three days when your endpoint returns a non-2xx response or times out. In practice this means a brief outage or a slow response on a busy day can result in the same event arriving two or more times over a multi-day window. Your receiver needs to gate on the event ID before any work starts.
The Stripe event object contains an id field that is stable across retries: evt_1234abc.... This is your deduplication key. The job is to answer one question before dispatching to the queue: have I already dispatched a job for this ID?
Redis approach:
$eventId = $payload['id']; // e.g. "evt_1Nt3vxKZ2eZvKYlo0XEo7d2v"
$cacheKey = 'webhook:stripe:' . $eventId;
$ttlSeconds = 7 * 24 * 3600; // 7 days
// SET NX: only set if the key does not exist
$isNew = Cache::store('redis')->add($cacheKey, 1, $ttlSeconds);
if (! $isNew) {
// Already seen this event: return 200 without dispatching
return response('Duplicate event', 200);
}
// Safe to dispatch
ProcessStripeWebhookJob::dispatch($payload);
return response('OK', 200);Laravel’s Cache::add() maps to Redis SET NX, which is atomic. Two concurrent requests for the same event ID will not both get true from this call. One wins, one returns 200 without dispatching. No double-processing.
The TTL matters. Set it to at least the maximum retry window of your webhook provider. For Stripe that is three days. Seven days gives a reasonable buffer. If you set it too short, a delayed retry from day four (after a provider incident) will slip through your dedup layer.
Database approach (when you need an audit trail):
Some teams prefer to store processed event IDs in a database table alongside the raw payload, both for deduplication and for replay capability. The trade-off compared to Redis is latency: a database unique-index lookup adds 5 to 15ms where Redis adds under 1ms: but the benefit is a permanent record.
// Schema migration
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider'); // 'stripe', 'github', etc.
$table->string('event_id')->unique();
$table->string('event_type');
$table->json('payload');
$table->string('status')->default('pending'); // pending, processed, failed
$table->timestamps();
});With the unique constraint on event_id, a second insert for the same ID throws a UniqueConstraintViolationException. Catch it and return 200:
try {
$event = WebhookEvent::create([
'provider' => 'stripe',
'event_id' => $payload['id'],
'event_type' => $payload['type'],
'payload' => $payload,
'status' => 'pending',
]);
} catch (\Illuminate\Database\UniqueConstraintViolationException $e) {
return response('Duplicate event', 200);
}
ProcessStripeWebhookJob::dispatch($event->id);
return response('OK', 200);Which approach to use:
Redis deduplication:
- Sub-millisecond lookup
- No persistent record of received events
- Works with ephemeral infrastructure that clears Redis on restart (use persistent Redis)
- Correct choice when events are high-volume and you do not need an audit trail
Database deduplication:
- 5 to 15ms lookup latency under normal conditions
- Permanent record of every received event: queryable for debugging and replay
- Survives a Redis failure
- Correct choice when your compliance or support workflow requires an audit of received events
For multi-tenant SaaS applications (hypothetically, a SOC platform receiving security event webhooks from multiple providers), the database approach pays for itself quickly. The ability to query “show me all events from provider X for tenant Y between these two timestamps” is worth the extra latency.
Layer Three: The Queue Job
The queue job receives either the raw payload (Redis dedup path) or a webhook event ID (database dedup path), and routes to a handler based on event type.
<?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;
class ProcessStripeWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30; // seconds between retries
public function __construct(private readonly array $payload) {}
public function handle(): void
{
$type = $this->payload['type'] ?? null;
match ($type) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded(),
'customer.subscription.deleted' => $this->handleSubscriptionCancelled(),
'invoice.payment_failed' => $this->handlePaymentFailed(),
default => null, // unhandled event type: not an error
};
}
private function handlePaymentSucceeded(): void
{
// Your business logic here
}
}A few details worth getting right here. The $tries = 3 limit prevents a permanently-broken handler from consuming queue workers indefinitely. The $backoff = 30 seconds gives transient failures (a brief database unavailability, a downstream API rate limit) time to recover before the next attempt. On final failure, the job lands in the failed jobs table, where it can be inspected and replayed manually.
Use a named queue for webhook jobs. This keeps them isolated from your application’s other background work. If a webhook processing spike saturates your queue workers, you do not want it starving password-reset emails or report generation.
ProcessStripeWebhookJob::dispatch($payload)->onQueue('webhooks');Ordering: When You Actually Need It
Most webhook handlers do not need strict ordering. A user profile update does not care whether it runs before or after a settings change. Process the most recent state and move on.
The cases where ordering matters are stateful resource lifecycles: subscription created, then upgraded, then cancelled. If customer.subscription.deleted processes before customer.subscription.updated, your subscriber might get cut off early or hit a broken intermediate state.
The instinct is to use queue ordering to solve this. Do not. Queues do not provide guaranteed FIFO across distributed workers in the general case, and Stripe does not guarantee delivery order. The correct solution is to make your state transitions idempotent with respect to order using a state machine.
Before applying any subscription event, check whether the transition is valid from the current state:
private function handleSubscriptionCancelled(): void
{
$subscriptionId = $this->payload['data']['object']['id'];
$subscription = Subscription::where('stripe_id', $subscriptionId)
->lockForUpdate()
->firstOrFail();
// Only cancel if the subscription is in a state that can be cancelled
if (! in_array($subscription->status, ['active', 'past_due', 'trialing'])) {
// Transition is not valid from this state: ignore silently
return;
}
$subscription->update(['status' => 'cancelled', 'cancelled_at' => now()]);
}lockForUpdate() acquires a row-level lock so two concurrent jobs processing different events for the same subscription do not race each other. The state check ensures that a late-arriving subscription.deleted event does not resurrect a subscription that has already been through cancellation.
The rule is: use database state to determine whether a transition is valid, not the order in which jobs arrive. This works even when events arrive out of order, which they will.
Failure Handling and Replay
Three failure modes matter in production:
Scenario one: your queue worker dies mid-job. Laravel marks a job as failed and does not acknowledge it in the queue. The job retries up to $tries times, then moves to the failed jobs table. Your data is not lost. Log the failure with enough context to debug it.
Scenario two: your endpoint is down when Stripe tries to deliver. Stripe retries for three days. If you come back online within that window, the events deliver normally. Your dedup layer handles any duplicates from retries that were already partially delivered. If you are down for more than three days (rare, but possible after an extended incident), Stripe provides a dashboard to manually re-trigger delivery for specific events.
Scenario three: a handler bug silently processes events incorrectly. This is the most dangerous case, because there is no error to detect. The argument for database-backed deduplication (storing the full payload) is that it enables replay. Fix the bug, change the status of affected rows from processed to pending, and re-dispatch:
// Replay failed webhook events from a given time window
WebhookEvent::where('provider', 'stripe')
->where('event_type', 'payment_intent.succeeded')
->where('status', 'failed')
->where('created_at', '>=', '2026-04-20 00:00:00')
->each(function (WebhookEvent $event) {
$event->update(['status' => 'pending']);
ProcessStripeWebhookJob::dispatch($event->id);
});This is why the database approach pays for itself on production systems: you can replay specific event types from a specific time window without contacting the provider. For Redis-only dedup, you depend entirely on the provider’s retry or replay tooling.
Extending to Multiple Providers
Once the pattern is in place for Stripe, adding GitHub, Shopify or any other provider is a matter of creating a new verification middleware and a new job class. The infrastructure is identical.
The one architectural decision worth making upfront: per-provider queue names. GitHub Actions webhook traffic patterns look nothing like Stripe billing events. Isolating them onto separate queues means a flood of GitHub push events does not delay your payment processing.
You can route by provider in your controller before dispatching:
$queue = match ($provider) {
'stripe' => 'webhooks-stripe',
'github' => 'webhooks-github',
default => 'webhooks-default',
};
ProcessWebhookJob::dispatch($provider, $payload)->onQueue($queue);Scale workers independently. If your CI/CD pipeline generates ten times more GitHub events than billing events, you can run five workers on webhooks-github and one on webhooks-stripe without changing any code.
What You End Up With
The final system is three components: a verification middleware, a controller with a dedup gate, and a queue job that routes to handlers.
The HTTP endpoint returns in under 20ms on every request. The signature check is a couple of hash operations. The dedup lookup is a single Redis read or a single database read. Everything else runs in the background.
When Stripe retries an event three days later because your server had a brief hiccup, the dedup layer catches it. The second delivery returns 200 before it even reaches the queue. Your customer does not get charged twice.
The receiver is stateless and scales horizontally. Adding instances means more capacity to absorb bursts. The queue workers scale independently based on backlog depth. Nothing is coupled.
The part that is easy to get wrong is also the part that is hardest to test in development: the interaction between retries, deduplication and state machine transitions under concurrent load. Write integration tests that fire the same event ID twice concurrently and assert that your business logic ran exactly once. That test will catch more production bugs than any unit test covering the happy path.
The receiver that fails quietly is worse than the one that fails loudly. At least the loud failure gives you a ticket to close.


