Handle Paystack Webhooks in Laravel
To handle Paystack webhooks in Laravel, add the webhook route to the CSRF exceptions, create a controller that reads the raw body with request()->getContent(), computes an HMAC SHA-512 hash, compares it to the X-Paystack-Signature header, and dispatches a Job for processing. Return response 200.
CSRF Exemption
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'webhooks/paystack',
];
Without this, Laravel returns 419 for all Paystack webhook requests because they lack a CSRF token.
Webhook Controller
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function handlePaystack(Request $request)
{
$signature = $request->header('X-Paystack-Signature');
if (!$signature) {
return response('Missing signature', 400);
}
$body = $request->getContent();
$secret = config('services.paystack.secret_key');
$computedHash = hash_hmac('sha512', $body, $secret);
if (!hash_equals($computedHash, $signature)) {
return response('Invalid signature', 400);
}
$event = json_decode($body, true);
// Process asynchronously
dispatch(new \App\Jobs\ProcessPaystackWebhook($event));
return response('OK', 200);
}
}
Webhook Processing Job
// app/Jobs/ProcessPaystackWebhook.php
namespace App\Jobs;
use App\Services\PaystackService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
class ProcessPaystackWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
private array $event;
public function __construct(array $event)
{
$this->event = $event;
}
public function handle(PaystackService $paystack): void
{
$eventType = $this->event['event'] ?? '';
$data = $this->event['data'] ?? [];
switch ($eventType) {
case 'charge.success':
$reference = $data['reference'] ?? null;
if ($reference) {
$paystack->verifyAndFulfill($reference);
}
break;
case 'subscription.create':
// Handle new subscription
break;
case 'invoice.payment_failed':
// Handle failed renewal
break;
}
}
}
Route Configuration
// routes/web.php
Route::post('/webhooks/paystack', [WebhookController::class, 'handlePaystack']);
Set the webhook URL in your Paystack dashboard: https://yourdomain.com/webhooks/paystack
Log Webhook Events
// Create a migration for webhook events
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('event_type');
$table->string('reference')->nullable();
$table->json('payload');
$table->boolean('processed')->default(false);
$table->timestamps();
});
// In the controller, log before dispatching:
\App\Models\WebhookEvent::create([
'event_type' => $event['event'] ?? 'unknown',
'reference' => $event['data']['reference'] ?? null,
'payload' => $event,
]);
Testing
// tests/Feature/WebhookTest.php
public function test_valid_webhook()
{
$payload = json_encode([
'event' => 'charge.success',
'data' => ['reference' => 'test_123', 'amount' => 500000],
]);
$signature = hash_hmac('sha512', $payload, config('services.paystack.secret_key'));
$response = $this->postJson('/webhooks/paystack', json_decode($payload, true), [
'X-Paystack-Signature' => $signature,
]);
$response->assertStatus(200);
}
Key Takeaways
- ✓Add the webhook route to VerifyCsrfToken $except array. Paystack does not send CSRF tokens.
- ✓Use request()->getContent() for the raw body. Do not use request()->all() as it gives parsed data.
- ✓Dispatch Laravel Jobs for async webhook processing. Return 200 immediately.
- ✓Use hash_hmac("sha512", $body, $secret) and hash_equals() for signature verification.
- ✓Make handlers idempotent. Paystack retries failed deliveries.
- ✓Log every webhook event in a database table for debugging.
Frequently Asked Questions
- Why does my webhook return 419?
- You forgot to add the webhook route to the CSRF exceptions in VerifyCsrfToken middleware. Paystack requests do not include Laravel CSRF tokens.
- Can I use Laravel Events instead of Jobs?
- Yes. Fire a PaystackWebhookReceived event and handle it with a listener. Both approaches work. Jobs are simpler for direct processing. Events are better if multiple parts of your app need to react to the same webhook.
- Should I use request()->all() or request()->getContent()?
- Use request()->getContent() for the raw body bytes. You need these for HMAC verification. request()->all() gives you parsed data, which may differ from the original bytes.
- How do I test webhooks locally?
- Use ngrok to expose your local server. Set the ngrok HTTPS URL as your webhook URL in the Paystack dashboard. For automated tests, compute the HMAC signature in your test and send it in the request header.
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