Receiving webhooks in Laravel
Verify before you touch the database, store the raw payload, process asynchronously, and dedupe on the sender event id. Plus the Cashier ordering trap that makes listeners fire too early.
A webhook endpoint is a public URL that accepts unsolicited POST requests from the internet. Anyone who finds it can call it. The sender will retry it, sometimes for a day. Deliveries arrive out of order. And the whole thing runs without a user session, so most of the framework’s instincts about who is calling do not apply.
Four rules cover almost all of it, and they have to happen in this order.
1. Verify Before Anything Touches the Database
Verification is a gate, not a step. Anything that runs before it runs on unauthenticated input from a stranger.
The most common way to get this wrong in Laravel is to type-hint a FormRequest, or to read $request->json(), and then verify. By then you have already parsed attacker-controlled input, and worse, you have lost the bytes you need.
Route::post('/webhooks/billing', VerifyWebhook::class);final class VerifySignature{ public function handle(Request $request, Closure $next): Response { $payload = $request->getContent(); // raw bytes, before any parsing $expected = hash_hmac('sha256', $payload, config('services.billing.secret')); $received = $request->header('X-Signature', '');
if (! hash_equals($expected, $received)) { return response()->noContent(status: 400); }
return $next($request); }}Two details do the work here:
$request->getContent() gives you the raw body exactly as received. If you take $request->all() and re-encode it, key order, whitespace and unicode escaping can all differ from what the sender signed, and verification fails intermittently, which is the worst kind of failure to debug.
hash_equals() is a constant-time comparison. === returns as soon as it hits a differing byte, which leaks through response timing how many leading bytes were correct. It is one function call and there is no reason not to.
Then exempt the route from CSRF, because there is no session and no token, and make sure the signature covers a timestamp so a captured request cannot be replayed a week later.
2. Store the Raw Payload Before You Process It
Write the delivery down before you act on it. One row, the raw body, the headers you care about, and the sender’s event id.
$delivery = WebhookDelivery::create([ 'provider' => 'billing', 'event_id' => $request->header('X-Event-Id'), 'event_type' => data_get($decoded, 'type'), 'payload' => $payload, // the raw string, not the array 'received_at'=> now(),]);This costs almost nothing and buys two things. If processing throws, you still have the evidence of exactly what you were sent, so you can reprocess without asking the provider for a replay. And when a provider says “we sent you that”, you can answer definitively rather than from a log line.
Store the raw string, not the decoded array. The decoded array is a lossy view of it, and the raw string is what the signature was over.
3. Acknowledge Fast, Process Later
A webhook response is an acknowledgement of receipt, not a report on what you did with it.
Providers time out in seconds, and a timeout looks identical to a failure, so a slow handler earns retries you did not need and did not deserve. Do the verification and the write inline, then hand off:
ProcessWebhookDelivery::dispatch($delivery);
return response()->noContent(status: 202);202 Accepted is the honest code: you have taken responsibility for the message without claiming to have acted on it yet.
4. Be Idempotent on the Sender’s Event Id
Assume at-least-once delivery, because that is what you are being given. Retries, replays and provider bugs all produce duplicates, and only the receiver can neutralise them.
Dedupe on the sender’s identifier, not on the payload contents or a hash of the body. A provider that re-sends a corrected payload under the same event id is telling you something, and a provider that sends two genuinely different events with identical bodies exists too.
The cleanest way to enforce it is a unique index rather than a check:
$table->string('event_id');$table->unique(['provider', 'event_id']);Then let the insert fail and treat the failure as success:
try { $delivery = WebhookDelivery::create([...]);} catch (UniqueConstraintViolationException) { return response()->noContent(status: 200); // already have it}A SELECT then INSERT has a race between the two statements, and webhook retries are exactly the traffic pattern that finds it: two deliveries of the same event arriving milliseconds apart, both seeing no existing row.
Handle out-of-order arrival too. Retries reorder events by construction, so a subscription.updated from 10:00 can land after the one from 10:05. If the payload carries a timestamp or a version, compare it against what you have stored and drop the stale one.
The Cashier Trap: WebhookHandled, Not WebhookReceived
This one costs an afternoon if you meet it the hard way.
Laravel Cashier dispatches two events for every Stripe webhook it processes:
| Event | When |
|---|---|
WebhookReceived | Before Cashier has done anything with the payload |
WebhookHandled | After Cashier has applied it to your database |
Listening on WebhookReceived is the obvious choice and it is usually wrong. Your listener fires before Cashier has updated the subscription, so anything that reads the subscription sees the old state. Worse, it mostly works in testing, because the gap is small and a local database is fast. It fails under load, where the two race.
// Fires before Cashier has updated anythingEvent::listen(WebhookReceived::class, RecordPlanChange::class);
// Fires after the subscription reflects the changeEvent::listen(WebhookHandled::class, RecordPlanChange::class);The general rule outside Cashier: if something else owns the write, listen for the event that fires after that write has committed, not the one that fires when the message arrived. The two are rarely the same event and the names rarely tell you which is which.
Testing It
Sign the fixture the same way the provider does, and assert on the failures rather than only the happy path.
it('rejects a payload whose signature does not match', function () { $payload = json_encode(['type' => 'invoice.paid']);
postJson('/webhooks/billing', [], [ 'X-Signature' => 'not-the-right-signature', ])->assertBadRequest();});
it('accepts the same event twice without processing it twice', function () { $payload = ['id' => 'evt_1', 'type' => 'invoice.paid'];
signedPost($payload)->assertNoContent(202); signedPost($payload)->assertNoContent(200);
expect(WebhookDelivery::count())->toBe(1);});The duplicate test is the one worth having. It is the case that happens in production every week and never happens while you are building the feature.