Bonaventure OgetoBy Bonaventure Ogeto|

Queueing Paystack Webhook Work with Laravel Queues

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.

Why Queue Paystack Webhooks in Laravel

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.

Setting Up the Queue Driver

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.

Exempting the Webhook Route from CSRF

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.

The Webhook Controller

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->getContent() returns the raw request body as a string. This is critical for signature verification. If you use $request->all() or $request->json(), Laravel parses and re-serializes the JSON, which may produce a different string than what Paystack signed.
  • hash_equals() does a constant-time comparison to prevent timing attacks. Never use === or strcmp() for signature comparison.
  • dispatch() pushes the job onto the queue and returns immediately. The controller does not wait for the job to finish.

Store your Paystack secret key in config/services.php:

// config/services.php
'paystack' => [
    'secret_key' => env('PAYSTACK_SECRET_KEY'),
],

The Job Class: Processing the Event

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.

Running the Queue Worker

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:

  • numprocs=2 runs two worker processes in parallel. Each handles one job at a time, so you can process two webhook events simultaneously.
  • --max-time=3600 restarts the worker every hour. This prevents memory leaks from accumulating over long runs and ensures the worker picks up any code changes deployed via your CI/CD pipeline.
  • --sleep=3 makes the worker wait 3 seconds between polling cycles when the queue is empty, reducing Redis load during quiet periods.
  • stopwaitsecs=3600 gives the worker up to an hour to finish its current job before being killed during a restart. This prevents jobs from being interrupted mid-processing.

After creating the Supervisor config, reload and start the workers:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start paystack-worker:*

Using Separate Queues for Priority

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.

Monitoring with Laravel Horizon

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:

  • Throughput. Jobs processed per minute, with graphs over time.
  • Runtime. How long each job takes, with trends that help you spot performance regressions.
  • Failed jobs. A list of every failed job with its payload, exception message, and stack trace. You can retry failed jobs directly from the dashboard.
  • Queue wait times. How long jobs sit in the queue before being processed. If this number climbs, you need more workers.

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.

Handling Permanently Failed Jobs

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:

  • Logging to a dedicated channel. Send critical webhook failures to a separate log file or a Slack channel that your team monitors actively.
  • Storing failed events in a dedicated table. Create a 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.
  • Automated reconciliation. Run a daily job that compares your payment records against the Paystack API to find any transactions that were received via webhook but not processed. See Storing Raw Webhook Payloads for Audit for the pattern.

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.

Key Takeaways

  • Your webhook controller should verify the signature, dispatch a job, and return response(null, 200). Keep the controller thin and push all business logic into the job class.
  • Laravel Queues support Redis, database, SQS, and Beanstalkd as queue drivers. Redis is the fastest and most common choice for Paystack webhook processing.
  • Exempt your webhook route from CSRF verification by adding it to the $except array in VerifyCsrfToken middleware. Paystack cannot send CSRF tokens.
  • Use the --tries and --backoff flags when running php artisan queue:work to control retry behavior. For payment webhooks, 5 tries with exponential backoff is a solid default.
  • Laravel Horizon provides a beautiful dashboard for monitoring your queues. It shows job throughput, failure rates, and recent jobs at a glance.
  • Every job must be idempotent. Laravel may retry a job after a partial success, and Paystack may send the same event more than once.
  • Store the raw webhook payload before dispatching the job. If the job fails permanently, you still have the original data for debugging and manual replay.

Frequently Asked Questions

Should I use the database or Redis driver for Paystack webhook queues?
Use Redis if you can. It is significantly faster than the database driver and handles high-throughput scenarios much better. The database driver works as a fallback if you cannot run Redis, but it creates table lock contention under load and adds latency to every job dispatch and pickup. For production payment processing, Redis is the standard choice.
Can I use Laravel Queues with a shared hosting environment?
Shared hosting is problematic for queue workers because you cannot run long-lived processes. The queue:work command needs to run continuously. Some shared hosts allow cron jobs, so you can use queue:work --stop-when-empty triggered by cron, but this is fragile and introduces delays. For a production Paystack integration, use a VPS, managed platform (Forge, Vapor, Railway), or containerized deployment where you control the processes.
What happens if Redis goes down while a webhook arrives?
If Redis is unavailable when your controller tries to dispatch the job, the dispatch() call will throw an exception. Your controller will return a 500 error, and Paystack will retry the webhook later. To handle this more gracefully, wrap the dispatch in a try-catch and fall back to storing the raw payload in your database for later processing.
How do I prevent duplicate processing when both Paystack retries and Laravel retries are in play?
Idempotency is your only reliable defense. Check whether the event has already been processed before doing any work. Use the transaction reference as the idempotency key. If your database already shows the order as paid for this reference, skip the processing and return silently. This handles duplicates from both Paystack retries and Laravel job retries.
Should I use queue:work or queue:listen in production?
Always use queue:work in production. The queue:listen command restarts the framework for every job, which is extremely slow. queue:work boots the framework once and keeps it in memory, processing jobs much faster. The only downside is that you need to restart the worker when you deploy new code (queue:restart does this), but that is a small price for the performance gain.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon