Laravel returns 419 Page Expired for Paystack webhooks because its VerifyCsrfToken middleware rejects POST requests without a CSRF token. The fix depends on your Laravel version. In Laravel 10 and below, add your webhook path to the $except array in App/Http/Middleware/VerifyCsrfToken.php. In Laravel 11, use the withMiddleware callback in bootstrap/app.php to exclude your webhook URI from CSRF verification.
When Paystack sends a webhook to your Laravel application, it sends a POST request with a JSON body and an X-Paystack-Signature header. Laravel's CSRF middleware (VerifyCsrfToken) intercepts the request and looks for a CSRF token. Since Paystack is a server, not a browser, it does not send a CSRF token. Laravel rejects the request with a 419 Page Expired response.
The 419 status code is Laravel-specific. It is not a standard HTTP status code. When you see 419 in your Paystack dashboard's webhook delivery log, it always means CSRF verification failed.
The fix is to exclude your webhook route from CSRF verification. How you do this depends on your Laravel version and where you define the route.
There are two approaches:
We will cover both approaches, starting with the cleanest one.
The simplest way to avoid CSRF issues is to define your webhook route in routes/api.php instead of routes/web.php. Routes in api.php use the "api" middleware group, which does not include CSRF verification.
// routes/api.php
use App\Http\Controllers\PaystackWebhookController;
Route::post('/webhooks/paystack', [PaystackWebhookController::class, 'handle']);
The URL for this route will be https://yourdomain.com/api/webhooks/paystack (Laravel prepends /api to all routes in api.php by default). Register this full URL in your Paystack dashboard.
If you do not want the /api prefix, you can customize it in app/Providers/RouteServiceProvider.php (Laravel 10) or bootstrap/app.php (Laravel 11).
In Laravel 11, the route registration in bootstrap/app.php looks like this:
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api', // Change this to '' for no prefix
// ...
)
->create();
This approach is preferred because it avoids CSRF entirely rather than creating exceptions. Your webhook endpoint is an API endpoint, not a web page, so it belongs in the API route group.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
If you need your webhook route in routes/web.php, you must add it to the CSRF exception list. In Laravel 10 and below, open app/Http/Middleware/VerifyCsrfToken.php and add the URI to the $except array:
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
'webhooks/paystack',
'webhooks/paystack/*', // Also exclude sub-paths if needed
];
}
The URI pattern supports wildcards. webhooks/paystack matches exactly that path, while webhooks/paystack/* matches any path starting with webhooks/paystack/. Include both if you might add sub-routes later.
Important: the URI in the $except array does not include the leading slash. webhooks/paystack, not /webhooks/paystack. This trips up developers who copy the path directly from their route definition.
Laravel 11 changed the middleware configuration approach. Instead of a separate VerifyCsrfToken middleware class, you configure CSRF exceptions in bootstrap/app.php:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/paystack',
'webhooks/paystack/*',
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})
->create();
The validateCsrfTokens(except: [...]) method replaces the old $except array. The syntax is different, but the behavior is identical. The URIs you list here will skip CSRF verification.
If you are upgrading from Laravel 10 to 11 and your webhook stops working, this is the most likely cause. Your old VerifyCsrfToken.php middleware class is no longer used, and the exceptions you had there are not automatically migrated. You need to add them to bootstrap/app.php manually.
Here is a complete Laravel controller for handling Paystack webhooks with signature verification:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class PaystackWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
// Get the webhook secret
$secret = config('services.paystack.webhook_secret');
if (empty($secret)) {
Log::error('PAYSTACK_WEBHOOK_SECRET not configured');
return response()->json(['error' => 'Server misconfigured'], 500);
}
// Get the signature header
$signature = $request->header('x-paystack-signature');
if (empty($signature)) {
return response()->json(['error' => 'Missing signature'], 400);
}
// Get the raw body for signature verification
$rawBody = $request->getContent();
// Compute HMAC-SHA512 hash
$computedHash = hash_hmac('sha512', $rawBody, $secret);
// Verify signature (timing-safe comparison)
if (!hash_equals($computedHash, $signature)) {
Log::warning('Invalid Paystack webhook signature');
return response()->json(['error' => 'Invalid signature'], 401);
}
// Parse the verified payload
$payload = json_decode($rawBody, true);
$eventType = $payload['event'] ?? '';
$data = $payload['data'] ?? [];
Log::info('Paystack webhook received: ' . $eventType);
// Route to the appropriate handler
match ($eventType) {
'charge.success' => $this->handleChargeSuccess($data),
'transfer.success' => $this->handleTransferSuccess($data),
'transfer.failed' => $this->handleTransferFailed($data),
'refund.processed' => $this->handleRefundProcessed($data),
default => Log::info('Unhandled event: ' . $eventType),
};
return response()->json(['received' => true], 200);
}
private function handleChargeSuccess(array $data): void
{
$reference = $data['reference'] ?? '';
$amount = $data['amount'] ?? 0;
Log::info('Payment successful: ' . $reference . ' Amount: ' . $amount);
// Your business logic:
// - Update order status
// - Send confirmation email
// - Grant access to product
}
private function handleTransferSuccess(array $data): void
{
$transferCode = $data['transfer_code'] ?? '';
Log::info('Transfer completed: ' . $transferCode);
}
private function handleTransferFailed(array $data): void
{
$transferCode = $data['transfer_code'] ?? '';
$reason = $data['reason'] ?? 'Unknown';
Log::warning('Transfer failed: ' . $transferCode . ' Reason: ' . $reason);
}
private function handleRefundProcessed(array $data): void
{
$reference = $data['transaction_reference'] ?? '';
Log::info('Refund processed for: ' . $reference);
}
}
And the config entry in config/services.php:
// config/services.php
return [
// ... other services
'paystack' => [
'secret_key' => env('PAYSTACK_SECRET_KEY'),
'public_key' => env('PAYSTACK_PUBLIC_KEY'),
'webhook_secret' => env('PAYSTACK_WEBHOOK_SECRET'),
],
];
A few notes:
$request->getContent() returns the raw body as a string. This is the correct method for HMAC verification. Do not use $request->all() or $request->json().hash_equals() is PHP's built-in constant-time string comparison. Always use this instead of === for comparing hashes.match expression (PHP 8.0+) is a clean way to route events. If you are on PHP 7.x, use a switch statement instead.If you want to keep your controller even cleaner, extract the signature verification into a custom middleware. This also lets you reuse the verification logic if you have multiple webhook endpoints (for example, from different payment providers).
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class VerifyPaystackSignature
{
public function handle(Request $request, Closure $next)
{
$secret = config('services.paystack.webhook_secret');
if (empty($secret)) {
Log::error('PAYSTACK_WEBHOOK_SECRET not configured');
abort(500, 'Server misconfigured');
}
$signature = $request->header('x-paystack-signature');
if (empty($signature)) {
abort(400, 'Missing signature');
}
$computedHash = hash_hmac('sha512', $request->getContent(), $secret);
if (!hash_equals($computedHash, $signature)) {
Log::warning('Invalid Paystack webhook signature');
abort(401, 'Invalid signature');
}
return $next($request);
}
}
Register the middleware in your route:
// routes/api.php
use App\Http\Controllers\PaystackWebhookController;
use App\Http\Middleware\VerifyPaystackSignature;
Route::post('/webhooks/paystack', [PaystackWebhookController::class, 'handle'])
->middleware(VerifyPaystackSignature::class);
Now your controller does not need to worry about signature verification at all. By the time the request reaches the controller, it has already been verified. This is a cleaner separation of concerns: the middleware handles security, and the controller handles business logic.
For webhook handlers that need to do more than a quick database update, push the event to a Laravel queue and return 200 immediately. This ensures Paystack gets a fast response even if your business logic takes time.
<?php
// app/Jobs/ProcessPaystackEvent.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;
use Illuminate\Support\Facades\Log;
class ProcessPaystackEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
public string $eventType,
public array $data
) {}
public function handle(): void
{
match ($this->eventType) {
'charge.success' => $this->handleChargeSuccess(),
'transfer.success' => $this->handleTransferSuccess(),
default => Log::info('Unhandled queued event: ' . $this->eventType),
};
}
private function handleChargeSuccess(): void
{
$reference = $this->data['reference'] ?? '';
Log::info('Processing payment in queue: ' . $reference);
// Heavy processing here
}
private function handleTransferSuccess(): void
{
Log::info('Processing transfer in queue: ' . ($this->data['transfer_code'] ?? ''));
}
}
// In your controller:
public function handle(Request $request): JsonResponse
{
$payload = json_decode($request->getContent(), true);
ProcessPaystackEvent::dispatch(
$payload['event'] ?? '',
$payload['data'] ?? []
);
return response()->json(['received' => true], 200);
}
For a comprehensive guide to queuing webhook work in Laravel, see Queueing Paystack Webhook Work with Laravel Queues.
Write a test to verify your webhook endpoint works correctly. Laravel's built-in testing tools make this straightforward:
<?php
namespace Tests\Feature;
use Tests\TestCase;
class PaystackWebhookTest extends TestCase
{
public function test_webhook_rejects_missing_signature(): void
{
$response = $this->postJson('/api/webhooks/paystack', [
'event' => 'charge.success',
'data' => ['reference' => 'test_123'],
]);
$response->assertStatus(400);
}
public function test_webhook_rejects_invalid_signature(): void
{
$response = $this->postJson('/api/webhooks/paystack', [
'event' => 'charge.success',
'data' => ['reference' => 'test_123'],
], [
'X-Paystack-Signature' => 'invalid_signature',
]);
$response->assertStatus(401);
}
public function test_webhook_accepts_valid_signature(): void
{
$payload = json_encode([
'event' => 'charge.success',
'data' => [
'reference' => 'test_ref_123',
'amount' => 500000,
'currency' => 'NGN',
],
]);
$secret = config('services.paystack.webhook_secret');
$signature = hash_hmac('sha512', $payload, $secret);
$response = $this->call(
'POST',
'/api/webhooks/paystack',
[],
[],
[],
[
'HTTP_X_PAYSTACK_SIGNATURE' => $signature,
'CONTENT_TYPE' => 'application/json',
],
$payload
);
$response->assertStatus(200);
$response->assertJson(['received' => true]);
}
}
Set a test webhook secret in your .env.testing file:
PAYSTACK_WEBHOOK_SECRET=test_webhook_secret_for_testing
The test manually computes the HMAC signature using the same secret and algorithm that Paystack uses, then sends it with the request. This verifies that your controller correctly validates the signature and returns 200 for properly signed requests.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course