Webhooks
Webhook support is local package logic built on top of spatie/laravel-webhook-client. It does not go through the outbound Saloon connector layer.
Looking for an end-to-end Laravel integration flow? Start with Webhook Processing.
Typical flow
- Register a webhook endpoint in your app.
- Exclude that endpoint from CSRF protection.
- Run the webhook client migration so valid calls can be stored.
- Run a queue worker so the stored call can be processed.
- Listen for
PaystackWebhookReceivedand handle the normalized event data.
What is supported
- Paystack signature verification using the configured secret key
- persisted webhook calls in the
webhook_callstable - queued processing through a package-provided webhook job
- dispatch of a generic parsed Paystack webhook event object
- typed webhook payload mapping for high-value charge, invoice, and subscription events
Register the webhook endpoint
use Illuminate\Support\Facades\Route;
Route::webhooks('paystack/webhook', 'paystack');If you prefer to avoid the route macro, the equivalent explicit route is:
use Illuminate\Support\Facades\Route;
use Spatie\WebhookClient\Http\Controllers\WebhookController;
Route::post('paystack/webhook', WebhookController::class)
->name('webhook-client-paystack');Local setup example
php artisan migrate
php artisan queue:workListen for processed Paystack webhooks
The package emits PaystackWebhookReceived after a valid webhook has been:
- signature-validated
- stored in
webhook_calls - processed by the queue job
Simple closure listener
use Illuminate\Support\Facades\Event;
use Maxiviper117\Paystack\Events\PaystackWebhookReceived;
Event::listen(PaystackWebhookReceived::class, function (PaystackWebhookReceived $event) {
$paystackEvent = $event->event;
if ($paystackEvent->event === 'charge.success') {
$reference = $paystackEvent->data['reference'] ?? null;
$status = $paystackEvent->data['status'] ?? null;
// Update your order, mark a payment as settled, etc.
}
});Typed payload listener
The generic envelope stays available for every webhook, but supported events can now be resolved into typed payload DTOs.
use Illuminate\Support\Facades\Event;
use Maxiviper117\Paystack\Data\Output\Webhook\Typed\ChargeSuccessWebhookData;
use Maxiviper117\Paystack\Events\PaystackWebhookReceived;
Event::listen(PaystackWebhookReceived::class, function (PaystackWebhookReceived $event) {
$webhook = $event->event;
if (! $webhook->is('charge.success')) {
return;
}
$typed = $webhook->typedData();
if (! $typed instanceof ChargeSuccessWebhookData) {
return;
}
$reference = $typed->reference;
$customerCode = $typed->customer?->customerCode;
$amount = $typed->amount;
// Update your order and record the settled transaction.
});Dedicated listener class
namespace App\Listeners;
use Maxiviper117\Paystack\Events\PaystackWebhookReceived;
class HandlePaystackWebhook
{
public function handle(PaystackWebhookReceived $event): void
{
$webhook = $event->event;
match (true) {
$webhook->is('charge.success') => $this->handleSuccessfulCharge($webhook),
$webhook->is('subscription.create') => $this->handleSubscriptionCreated($webhook),
default => null,
};
}
protected function handleSuccessfulCharge($webhook): void
{
$typed = $webhook->typedData();
if (! $typed instanceof \Maxiviper117\Paystack\Data\Output\Webhook\Typed\ChargeSuccessWebhookData) {
return;
}
// Your application logic here.
}
protected function handleSubscriptionCreated($webhook): void
{
$typed = $webhook->typedData();
if (! $typed instanceof \Maxiviper117\Paystack\Data\Output\Webhook\Typed\SubscriptionCreatedWebhookData) {
return;
}
// Your application logic here.
}
}Queue the listener itself
If your app-side webhook handling is expensive, make your listener queueable too:
namespace App\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Maxiviper117\Paystack\Events\PaystackWebhookReceived;
class HandlePaystackWebhook implements ShouldQueue
{
public function handle(PaystackWebhookReceived $event): void
{
// Long-running app logic here.
}
}What you receive in the event
PaystackWebhookReceived gives you:
$event->webhookCall: the stored webhook call model$event->event: a normalizedPaystackWebhookEventDataobject
Useful properties on $event->event:
event: full Paystack event name such ascharge.successresourceType: inferred resource prefix such aschargeid: resource ID when presentdomain: Paystack domain when presentoccurredAt:CarbonImmutable|nullresolved frompaid_at,created_at, or payload fallback when availabledata: the nested Paystackdataobjectpayload: the full decoded webhook payload
Useful methods on $event->event:
is('charge.success'): exact event-name match helpersupportsTypedData(): tells you whether the package has a typed DTO for that eventtypedData(): returns a typed webhook DTO for supported events, otherwisenull
Example:
Event::listen(PaystackWebhookReceived::class, function (PaystackWebhookReceived $event) {
logger()->info('Paystack webhook received', [
'event' => $event->event->event,
'resource_type' => $event->event->resourceType,
'id' => $event->event->id,
'occurred_at' => $event->event->occurredAt?->toAtomString(),
'reference' => $event->event->data['reference'] ?? null,
]);
});Typed webhook events currently supported
Typed DTOs are currently available for:
charge.successinvoice.createinvoice.updateinvoice.payment_failedsubscription.createsubscription.not_renewsubscription.disable
Example for invoice handling:
use Illuminate\Support\Facades\Event;
use Maxiviper117\Paystack\Data\Output\Webhook\Typed\InvoiceCreatedWebhookData;
use Maxiviper117\Paystack\Events\PaystackWebhookReceived;
Event::listen(PaystackWebhookReceived::class, function (PaystackWebhookReceived $event) {
if (! $event->event->is('invoice.create')) {
return;
}
$typed = $event->event->typedData();
if (! $typed instanceof InvoiceCreatedWebhookData) {
return;
}
logger()->info('Invoice created', [
'invoice_code' => $typed->invoiceCode,
'subscription_code' => $typed->subscriptionCode,
'customer_code' => $typed->customerCode,
'paid' => $typed->paid,
]);
});Example for subscription lifecycle handling:
use Illuminate\Support\Facades\Event;
use Maxiviper117\Paystack\Data\Output\Webhook\Typed\SubscriptionCreatedWebhookData;
use Maxiviper117\Paystack\Data\Output\Webhook\Typed\SubscriptionDisabledWebhookData;
use Maxiviper117\Paystack\Events\PaystackWebhookReceived;
Event::listen(PaystackWebhookReceived::class, function (PaystackWebhookReceived $event) {
$typed = $event->event->typedData();
if ($typed instanceof SubscriptionCreatedWebhookData) {
// Provision or mark the subscription active in your app.
return;
}
if ($typed instanceof SubscriptionDisabledWebhookData) {
// Mark the subscription inactive or complete in your app.
}
});Inspect stored webhook calls
You can inspect stored payloads through the custom webhook call model:
use Maxiviper117\Paystack\Models\PaystackWebhookCall;
$latestWebhook = PaystackWebhookCall::query()->latest()->first();
$rawBody = $latestWebhook?->rawBody();
$decodedInput = $latestWebhook?->inputPayload();Setup requirements
- keep
PAYSTACK_SECRET_KEYconfigured so signature validation can succeed - run the webhook client migration so the
webhook_callstable exists - run a queue worker for webhook processing
- exclude your webhook route from CSRF protection in Laravel 11 or 12
Example CSRF exclusion in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'paystack/webhook',
]);
})Returned event type
Processed webhooks dispatch PaystackWebhookReceived, which contains PaystackWebhookEventData.
Security notes
- invalid signatures are rejected before the webhook is stored
- valid but malformed payloads are stored and then fail during processing, which gives you an audit trail
- the package validates the raw request body exactly as Paystack sent it
- typed payload mapping only resolves exact supported event names and rejects missing required fields or malformed timestamps instead of silently coercing them
- typed DTOs help with ergonomics, but your application still owns idempotency, authorization, and side-effect safety