Webhooks are how your Laravel app talks to payment providers, CRMs, and CI systems, and they are also the most common place a clean codebase silently loses money: a missed Stripe payment_intent.succeeded event means an order that never gets marked paid. This guide covers both directions of Laravel webhooks: receiving them with signature verification and fast responses, and sending them with retries, signing, and idempotency.
How do you receive webhooks in Laravel safely?
Verify the sender's HMAC signature before touching the payload, exempt the route from CSRF, and return a 2xx within seconds by deferring real work to a queued job. Anything slower risks the provider timing out and retrying, which doubles your processing.
The receiving side has three hard requirements. First, the route must bypass CSRF token verification, because webhook senders will never have your session token. Second, you must read the raw request body, since $request->json() re-encodes the payload and any difference in whitespace or key ordering breaks the signature check. Third, the signature must be verified before the payload is trusted.
// routes/web.php or routes/api.php
Route::post('/webhooks/stripe', StripeWebhookController::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
// app/Http/Controllers/StripeWebhookController.php
class StripeWebhookController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$signature = $request->header('Stripe-Signature', '');
$secret = config('services.stripe.webhook_secret');
$computed = hash_hmac(
'sha256',
$request->getContent(),
$secret
);
if (! hash_equals($computed, $signature)) {
abort(401, 'Invalid webhook signature');
}
$payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);
ProcessStripeWebhook::dispatch($payload)
->onQueue('webhooks');
return response()->json(['received' => true]);
}
}Two details matter here. hash_equals prevents timing attacks on the comparison; a plain === on strings leaks information about the expected signature one character at a time. And dispatching a job instead of processing inline keeps your response time flat even when the handler is slow. Providers like Stripe expect an acknowledgment quickly, and their retry schedule kicks in on timeouts.
Some providers (Stripe included) sign a combination of a timestamp and the payload rather than the payload alone, to defend against replay attacks. Check the provider's docs for the exact signing scheme before writing your verification code. The pattern above is the minimal case.
Should webhook processing run on a Laravel queue?
Yes, almost always. A queue separates acknowledgment from processing, which gives you three things for free: fast 2xx responses to satisfy sender timeouts, automatic retries with backoff through the queue system, and a failed_jobs trail when a webhook permanently fails. See Laravel Queue Best Practices for Reliable Background Jobs for the retry and timeout settings this depends on.
The job itself should be idempotent. Providers retry aggressively, and a duplicate invoice.paid event must not double-apply a credit. The cheapest defense is a processed-events table with a unique constraint on the provider's event ID:
Schema::create('processed_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('event_id')->unique();
$table->timestamp('processed_at');
$table->timestamps();
});
// Inside the job:
$inserted = DB::table('processed_webhook_events')->insertOrIgnore([
'provider' => 'stripe',
'event_id' => $payload['id'],
'processed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
if ($inserted === 0) {
return; // duplicate delivery, already handled
}insertOrIgnore returns 0 when the unique constraint blocks the row, which makes the dedup check atomic. A firstOrCreate followed by a check has a race window between two concurrent workers. If you use PostgreSQL, this pairs well with the transactional patterns in PostgreSQL Row Locking in Laravel.
How do you send webhooks from Laravel?
Use the HTTP client with a signed payload, a short timeout, and bounded retries. Sign the exact bytes you transmit, never a re-serialized array, because the receiver will verify the signature against the raw body it received.
class SendWebhookJob implements ShouldQueue
{
public int $tries = 5;
public array $backoff = [10, 60, 300, 900];
public function __construct(
public string $endpoint,
public array $payload,
) {}
public function handle(): void
{
$body = json_encode([
'id' => (string) Str::uuid(),
'created_at' => now()->toIso8601String(),
'data' => $this->payload,
], JSON_THROW_ON_ERROR);
$signature = hash_hmac('sha256', $body, config('services.webhook.secret'));
$response = Http::withHeaders([
'X-Signature' => $signature,
'Content-Type' => 'application/json',
])->timeout(5)
->withBody($body, 'application/json')
->post($this->endpoint);
$response->throw();
}
}The withBody() call is the trap most implementations hit. If you use ->post($url, $array), the HTTP client serializes the array itself, and the bytes on the wire may differ from the bytes you signed. Signing the encoded string and sending it verbatim guarantees the receiver's HMAC check passes.
On retries: 5xx responses and connection failures should retry, but a 4xx response means the receiver rejects your payload permanently. Re-throwing on a 400 burns all five attempts against an endpoint that will never accept it. Consider failing fast on 4xx and reserving retries for 429 and 5xx:
if ($response->status() === 429 || $response->serverError()) {
throw new RuntimeException('Retryable webhook failure');
}
if ($response->clientError()) {
// Do not retry a permanent rejection; log and surface it.
Log::warning('Webhook endpoint rejected payload', [
'endpoint' => $this->endpoint,
'status' => $response->status(),
]);
return;
}
$response->throw();Receiving vs sending: what differs at scale?
| Concern | Receiving | Sending |
|---|---|---|
| Trust boundary | Verify the caller's signature before parsing | Sign the exact transmitted bytes |
| Latency budget | Respond in seconds, process later | Allow the receiver 5 to 10 seconds |
| Duplicate handling | Dedup on provider event ID | Include a stable event ID so receivers can dedup |
| Retry policy | Queue retries, respect sender's replay schedule | Exponential backoff, stop on 4xx |
| Failure visibility | Failed jobs table plus provider dashboards | Dead-letter queue and per-endpoint health tracking |
The recommendation: treat outbound Laravel webhooks as a product feature if third parties consume them, with per-endpoint disablement after repeated failures. A single chronically failing subscriber should not slow the shared queue for everyone else, so give each consumer endpoint its own queue or rate limit.
How do you test webhook handlers?
Test the full path: signature failure returns 401, a valid signature enqueues exactly one job, and the job applies the state change exactly once even when run twice. Laravel's HTTP client faking covers the sending direction, and queue faking covers receiving. The patterns in Laravel Testing: Why 1,800 Passing Tests Still Ship Bugs apply directly here.
public function test_rejects_invalid_signature(): void
{
$response = $this->postJson('/webhooks/stripe', ['type' => 'invoice.paid'], [
'Stripe-Signature' => 'invalid',
]);
$response->assertStatus(401);
Queue::assertNothingPushed();
}
public function test_valid_signature_enqueues_job_once(): void
{
Queue::fake();
$body = json_encode(['id' => 'evt_123', 'type' => 'invoice.paid']);
$signature = hash_hmac('sha256', $body, config('services.stripe.webhook_secret'));
$this->postJson('/webhooks/stripe', $body, [
'Stripe-Signature' => $signature,
'CONTENT_TYPE' => 'application/json',
])->assertOk();
Queue::assertPushedOn('webhooks', ProcessStripeWebhook::class);
}For the sending side, Http::fake() lets you assert the signature header matches hash_hmac of the captured request body, which catches the re-serialization bug described above before it reaches a customer's endpoint.
Frequently asked questions
Do I need a package to handle webhooks in Laravel?
Not for most cases. Signature verification is a few lines of hash_hmac and hash_equals, and delivery is an HTTP client call inside a queued job. Packages earn their weight when they handle many providers' signature schemes or expose a Laravel-native event layer, but a single Stripe or GitHub integration is often simpler as first-party code you fully control.
What HTTP status should a webhook endpoint return?
Return 200 for any payload that passed signature verification and was accepted for processing, even if your business logic later decides to ignore it. Return 401 for signature failures and 400 only for structurally malformed payloads the sender should fix. Returning a 500 for business-rule mismatches triggers pointless retries from the provider.
How do I stop webhook replay attacks?
Include a timestamp in the signed material and reject payloads older than a few minutes, then dedup on the event ID as shown above. A signature alone proves the payload came from a holder of the secret, but it does not prove freshness, so an intercepted request can be replayed indefinitely unless you check the timestamp against the current time.
