Reliable Webhook Processing in Laravel
A practical Laravel webhook design covering signatures, duplicate delivery, fast acknowledgement, queues, ordering, recovery, and useful observability.
A webhook endpoint is not a normal controller action. The sender controls when it is called, deliveries can repeat, events may arrive out of order, and a timeout does not tell either side whether the work completed. The safest design is a small receiving boundary followed by durable, idempotent processing. That sounds formal, but it can be implemented with ordinary Laravel, a database table, and a queue.
Verify the exact bytes that arrived
Signature verification happens before JSON is trusted. Providers usually sign the raw request body together with a timestamp. Decoding and encoding the payload again can change whitespace, escaping, or field order, so I pass the untouched body from $request->getContent() to the provider’s verification routine.
The signing secret belongs in managed configuration, not source code or request logs. I also enforce the provider’s timestamp tolerance to reduce replay risk and support secret rotation with a short overlap when the provider allows multiple active secrets. An invalid signature receives a generic response; logs record a safe reason and correlation value, never the secret or complete sensitive payload.
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
try {
$event = Webhook::constructEvent(
$payload,
$signature,
config('services.stripe.webhook_secret'),
);
} catch (UnexpectedValueException|SignatureVerificationException) {
return response()->json(['message' => 'Invalid webhook'], 400);
}Store the delivery before doing product work
After verification, I insert a webhook receipt containing the provider, provider event ID, event type, received time, payload, processing status, attempt count, and last safe error. A unique database constraint on provider plus event ID is the real duplicate guard. A cache check or a queue’s uniqueness setting can reduce extra work, but neither replaces durable idempotency at the data boundary.
The endpoint acknowledges quickly after the receipt is committed and processing has been scheduled. Sending email, calling another API, or rebuilding a projection inside the request increases the chance that the provider times out and retries. If the receipt cannot be stored, I return a non-success response so the sender has a reason to retry instead of accepting an event I may lose.
$receipt = WebhookReceipt::firstOrCreate(
[
'provider' => 'stripe',
'provider_event_id' => $event->id,
],
[
'event_type' => $event->type,
'payload' => $event->toArray(),
'received_at' => now(),
'status' => 'pending',
],
);
if ($receipt->wasRecentlyCreated) {
ProcessWebhook::dispatch($receipt->id)->afterCommit();
}
return response()->noContent();Make the business effect idempotent too
Deduplicating the delivery is necessary, but it does not close every failure window. A worker can update an order and crash before marking the receipt complete. The retry then sees the same pending receipt. I put the business change and completion marker in one database transaction whenever they share the same database.
The domain operation also needs a stable rule. A payment event can record a provider payment ID under a unique constraint; a subscription event can apply only if its provider version or timestamp is newer than the stored state. I avoid using the webhook receipt ID as the only idempotency key because the same business action may be represented by more than one provider event.
- Use database constraints for identities that must be unique
- Write the domain change and receipt status in one transaction
- Make external side effects use provider idempotency keys when available
- Store a safe failure reason and the next retry time
- Move permanently invalid events to a visible failed state
Assume events can arrive out of order
Many providers explicitly do not guarantee event order. A paid invoice can arrive before the local subscription-created handler runs. Building a state machine around arrival order makes occasional network timing into a product bug. When the event contains enough authoritative state, I compare versions or timestamps before applying it. When it does not, I fetch the current object from the provider using the identifier in the event.
Fetching current state trades an extra API call for a clearer source of truth, so it needs its own timeout, retry policy, and rate-limit handling. I do not retry a malformed payload forever. Temporary transport errors, provider 5xx responses, and lock timeouts can retry with backoff; unknown event types and broken invariants need review or explicit ignore rules.
Separate delivery success from processing success
A provider dashboard can show that the endpoint returned 204 while the internal job later failed. I monitor both stages. Receiving metrics cover signature failures, duplicate rate, response latency, and storage errors. Processing metrics cover pending count, oldest pending age, attempts, failure reason, event type, and time from provider creation to completion.
Recovery should be an application feature, not a manual database edit. I keep an authenticated command or admin action that retries a specific receipt using the same idempotent processor. For wider incidents, I can select failed or stale receipts by time and event type, preview the count, and replay them in controlled batches. The original payload remains immutable so a replay means the same thing as the first attempt.
Test the failure windows
My tests send a valid signature, an invalid signature, a modified raw body, the same event twice, two different events for one object, and events in reverse order. I force the worker to fail after its domain write and verify that a retry does not repeat the effect. Queue tests also verify that processing is dispatched after commit, not while the receipt transaction is still open.
Those cases are more valuable than testing ten happy event types. A webhook integration becomes trustworthy when duplicates, delays, crashes, and replays are routine inputs with predictable outcomes.
Use this in practice
- Verify signatures against the raw request body
- Store every accepted delivery under a unique provider event ID
- Acknowledge only after durable storage
- Dispatch processing after the transaction commits
- Make domain effects independently idempotent
- Handle out-of-order events deliberately
- Monitor oldest pending age and provide a safe replay path
