Use Laravel Queues to process Paystack webhooks by creating a controller that verifies the x-paystack-signature header, dispatches a queued job containing the webhook payload, and returns a 200 response immediately. A separate queue worker process picks up the job and runs your business logic. If the job fails, Laravel retries it automatically based on your retry configuration. This pattern keeps Paystack happy with fast responses while ensuring every event gets processed reliably.
When a Paystack webhook hits your Laravel application, the HTTP request ties up a PHP worker for the entire duration of your handler. If your handler queries the database, sends an email, calls another API, and updates several records, that could take several seconds. During that time, Paystack is waiting for a response. If it waits too long, it marks the delivery as failed and schedules a retry.
Laravel's queue system solves this cleanly. Your controller receives the webhook, drops the payload onto a queue, and returns 200 in milliseconds. A separate worker process (running php artisan queue:work) picks up the job and does the heavy lifting. If the job fails, Laravel retries it automatically. If the worker crashes, jobs wait in the queue until the worker comes back.
This is not over-engineering. It is the minimum reliable architecture for processing payment webhooks. Any Paystack integration handling more than a handful of transactions per day should use this pattern. The only scenario where inline processing (no queue) is acceptable is a prototype you plan to rewrite before going live.
Laravel supports multiple queue backends. For Paystack webhook processing, Redis is the best choice: it is fast, it persists jobs, and it handles the concurrency patterns you need. If you cannot run Redis, the database driver is a workable fallback.
Install the Redis PHP extension and the Predis package:
pecl install redis
composer require predis/predis
Configure your queue connection in .env:
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
Laravel's config/queue.php already has a Redis connection configured out of the box. The defaults work for most setups. The key setting to check is retry_after, which controls how long Laravel waits before assuming a job is stuck and releasing it back to the queue:
// config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90, // seconds
'block_for' => null,
],
The retry_after value should be longer than your longest-running job. If a webhook job could take up to 30 seconds (sending emails, calling external APIs), set retry_after to at least 60. If it is too short, Laravel releases the job while it is still being processed, and another worker picks it up, causing duplicate processing.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Laravel's CSRF protection rejects any POST request that does not include a valid CSRF token. Paystack's webhook requests do not include one (they cannot; they come from Paystack's servers, not from a form on your site). You need to explicitly exempt your webhook route.
Open app/Http/Middleware/VerifyCsrfToken.php and add your webhook path to the $except array:
// app/Http/Middleware/VerifyCsrfToken.php
class VerifyCsrfToken extends Middleware
{
protected $except = [
'webhook/paystack',
];
}
This is safe because your webhook route has its own authentication: the HMAC signature verification. CSRF protection exists to prevent browsers from submitting forms on behalf of logged-in users. Paystack webhooks are server-to-server requests with no browser involved, so CSRF does not apply. See Webhooks and CSRF Protection in Laravel for the full security analysis.
Create a controller that verifies the signature and dispatches a job:
// app/Http/Controllers/PaystackWebhookController.php
namespace App\Http\Controllers;
use App\Jobs\ProcessPaystackWebhook;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class PaystackWebhookController extends Controller
{
public function handle(Request $request): Response
{
$payload = $request->getContent();
$signature = $request->header('X-Paystack-Signature', '');
$secret = config('services.paystack.secret_key');
$expected = hash_hmac('sha512', $payload, $secret);
if (!hash_equals($expected, $signature)) {
return response('Invalid signature', 401);
}
$event = json_decode($payload, true);
ProcessPaystackWebhook::dispatch(
$event['event'],
$event['data']
);
return response(null, 200);
}
}
Register the route in routes/web.php (or routes/api.php if you prefer):
// routes/web.php
use App\Http\Controllers\PaystackWebhookController;
Route::post('webhook/paystack', [PaystackWebhookController::class, 'handle']);
Key points about this controller:
$request->all() or $request->json(), Laravel parses and re-serializes the JSON, which may produce a different string than what Paystack signed.=== or strcmp() for signature comparison.Store your Paystack secret key in config/services.php:
// config/services.php
'paystack' => [
'secret_key' => env('PAYSTACK_SECRET_KEY'),
],
Generate a job class with Artisan:
php artisan make:job ProcessPaystackWebhook
Fill in the job with your processing logic:
// app/Jobs/ProcessPaystackWebhook.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 ProcessPaystackWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public array $backoff = [10, 30, 60, 120, 300];
public function __construct(
public string $eventType,
public array $data,
) {}
public function handle(): void
{
Log::info('Processing Paystack event: ' . $this->eventType, [
'reference' => $this->data['reference'] ?? 'N/A',
]);
match ($this->eventType) {
'charge.success' => $this->handleChargeSuccess(),
'transfer.success' => $this->handleTransferSuccess(),
'transfer.failed' => $this->handleTransferFailed(),
'refund.processed' => $this->handleRefundProcessed(),
default => Log::info('Ignoring event: ' . $this->eventType),
};
}
private function handleChargeSuccess(): void
{
$reference = $this->data['reference'];
$amountNaira = $this->data['amount'] / 100;
// Idempotency: check if already processed
// $order = Order::where('reference', $reference)->first();
// if ($order && $order->status === 'paid') return;
// Update order
// Order::where('reference', $reference)->update(['status' => 'paid']);
Log::info('charge.success processed: ' . $reference . ' NGN ' . $amountNaira);
}
private function handleTransferSuccess(): void
{
Log::info('transfer.success: ' . ($this->data['reference'] ?? ''));
}
private function handleTransferFailed(): void
{
Log::info('transfer.failed: ' . ($this->data['reference'] ?? ''));
}
private function handleRefundProcessed(): void
{
Log::info('refund.processed for transaction');
}
public function failed(\Throwable $exception): void
{
Log::critical('Paystack webhook permanently failed', [
'event' => $this->eventType,
'reference' => $this->data['reference'] ?? 'N/A',
'error' => $exception->getMessage(),
]);
// Alert your team: Slack, email, PagerDuty
}
}
The $tries = 5 property tells Laravel to retry this job up to 5 times. The $backoff array specifies the delay (in seconds) before each retry: 10 seconds after the first failure, 30 after the second, and so on up to 5 minutes. This gives transient issues time to resolve without losing the event.
The failed() method runs when the job exhausts all its retries. This is your last chance to alert someone that a payment event was not processed. Do not skip this method. In a payment system, a permanently failed webhook job means money moved but your system does not know about it.
In development, start the queue worker in a separate terminal:
php artisan queue:work --tries=5 --backoff=10,30,60,120,300
The worker runs as a long-lived process, polling Redis for new jobs and processing them as they arrive. When you make code changes, restart the worker with php artisan queue:restart because the worker caches your application code in memory.
For production, run the worker as a managed process. With Supervisor (the most common approach on Linux servers):
[program:paystack-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=5 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/paystack-worker.log
stopwaitsecs=3600
Key settings:
After creating the Supervisor config, reload and start the workers:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start paystack-worker:*
As your integration grows, you might want to process payment events faster than other event types. Laravel makes this easy with named queues.
Dispatch jobs to different queues based on event type:
// In PaystackWebhookController
$queue = match (true) {
str_starts_with($event['event'], 'charge.') => 'payments',
str_starts_with($event['event'], 'transfer.') => 'transfers',
default => 'default',
};
ProcessPaystackWebhook::dispatch($event['event'], $event['data'])
->onQueue($queue);
Run separate workers for each queue:
# High-priority payment worker
php artisan queue:work redis --queue=payments --tries=5
# Transfer worker
php artisan queue:work redis --queue=transfers --tries=5
# Default worker (catches everything else)
php artisan queue:work redis --queue=default --tries=5
You can also run a single worker that processes multiple queues in priority order:
php artisan queue:work redis --queue=payments,transfers,default
With this syntax, the worker processes all jobs in the "payments" queue before touching "transfers," and all "transfers" jobs before "default." This ensures payment completions (a customer just paid and is waiting for their product) always get processed before less urgent events.
Laravel Horizon is a queue management dashboard built specifically for Laravel and Redis. It gives you real-time visibility into your queues, workers, and job metrics.
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
Access the dashboard at /horizon in your browser. Horizon shows:
Configure Horizon supervisors in config/horizon.php to match your queue setup:
// config/horizon.php
'environments' => [
'production' => [
'payment-supervisor' => [
'connection' => 'redis',
'queue' => ['payments'],
'balance' => 'auto',
'maxProcesses' => 4,
'tries' => 5,
],
'default-supervisor' => [
'connection' => 'redis',
'queue' => ['transfers', 'default'],
'balance' => 'auto',
'maxProcesses' => 2,
'tries' => 5,
],
],
],
In production, run Horizon instead of queue:work:
php artisan horizon
Horizon manages worker processes for you, auto-balancing them across queues based on load. It also provides a built-in notification system that can alert you via Slack or SMS when jobs fail.
Protect the Horizon dashboard in production by configuring the authorization gate in app/Providers/HorizonServiceProvider.php. Only admin users should have access.
When a job fails all its retries, Laravel stores it in the failed_jobs database table (run php artisan queue:failed-table and php artisan migrate if you have not already). You can inspect and retry failed jobs from the command line:
# List all failed jobs
php artisan queue:failed
# Retry a specific failed job
php artisan queue:retry 5
# Retry all failed jobs
php artisan queue:retry all
For a Paystack integration, permanently failed jobs need immediate attention. The failed() method on your job class is the right place to trigger alerts. Beyond the method shown earlier, consider:
failed_webhook_events table that stores the event type, data, error message, and timestamp. This gives you a searchable history separate from Laravel's generic failed_jobs table.The most dangerous situation is a failed job you do not notice. In a payment system, silence is worse than noise. Better to get a false alarm about a retry that eventually succeeded than to miss a permanently failed event that left a customer without their purchase.
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