Webhooks
Webhooks are essential for handling asynchronous events from Paystack, such as successful payments, subscription renewals, and transfer updates. This package comes with a built-in webhook controller and verification middleware to make handling these events effortless.
Configuration
1. Automatic Route Registration
The package automatically registers the webhook route for you. By default, this endpoint is available at /paystack/webhook.
You can customize this path in your config/paystack.php file:
// config/paystack.php
'webhook_path' => 'paystack/webhook',This means your full webhook URL will be: https://your-domain.com/paystack/webhook
2. Dashboard Configuration
You must configure this URL in your Paystack Dashboard.
- Log in to your Paystack Dashboard.
- Go to Settings > API Keys & Webhooks.
- Enter your URL in the Webhook URL field.
- Save changes.
3. CSRF Exemption
Since webhooks are external POST requests, you must exclude the webhook route from CSRF protection in your bootstrap/app.php.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'paystack/webhook', // or your custom path
]);
})Security
The package automatically secures your webhook endpoint using the VerifyPaystackWebhook middleware. This middleware:
- Validates the
x-paystack-signatureheader. - Confirms the signature matches your
PAYSTACK_SECRET_KEY. - Rejects any request that fails validation with a
403 Forbiddenresponse.
You do not need to manually implement signature verification.
Handling Events
The package emits Laravel events for every valid webhook received. You can listen to these events in your application to perform custom logic (e.g., sending emails, updating orders).
Available Events
| Event Class | Paystack Event | Description |
|---|---|---|
Turndale\Paystack\Events\PaymentSuccess | charge.success | A payment was successful. |
Turndale\Paystack\Events\SubscriptionCreated | subscription.create | A new subscription was created. |
Turndale\Paystack\Events\SubscriptionDisabled | subscription.disable | A subscription was disabled (payment failed/cancelled). |
Turndale\Paystack\Events\SubscriptionNotRenew | subscription.not_renew | A subscription is set to not renew. |
Turndale\Paystack\Events\InvoiceCreated | invoice.create | An invoice was generated. |
Turndale\Paystack\Events\InvoiceUpdated | invoice.update | An invoice was updated. |
Turndale\Paystack\Events\InvoicePaymentFailed | invoice.payment_failed | An invoice payment failed. |
Turndale\Paystack\Events\ChargeDisputeCreated | charge.dispute.create | A charge dispute was created. |
Turndale\Paystack\Events\TransferSuccess | transfer.success | A transfer was completed. |
Turndale\Paystack\Events\TransferFailed | transfer.failed | A transfer failed. |
Turndale\Paystack\Events\WebhookReceived | All | Dispatched for every webhook before specific handling. |
Turndale\Paystack\Events\WebhookHandled | Specific | Dispatched after a specific handler method is called. |
Event Handling Strategy
This package uses a dual-layer event system to give you maximum flexibility: a General Event for logging and Specific Events for business logic.
1. WebhookReceived (The General Event)
- Purpose: Dispatches for every single request that hits your webhook URL, regardless of the event type.
- Developer Use Case: Best for logging all incoming Paystack traffic to a database table or sending every raw payload to a service like Webhook.site for debugging. Ensures that even if a specific event class hasn't been created for a new Paystack feature, you can still catch it here.
2. Specific Events (e.g., PaymentSuccess, SubscriptionCreated)
- Purpose: Dispatches only when the Paystack event string matches the specific handler.
- Developer Use Case: Best for core business logic, such as upgrading a school's plan, activating a student limit, or sending a specific "Payment Received" email.
Event Summary Table
| Paystack Event String | Package Event Class |
|---|---|
| Any Event | Turndale\Paystack\Events\WebhookReceived |
charge.success | Turndale\Paystack\Events\PaymentSuccess |
subscription.create | Turndale\Paystack\Events\SubscriptionCreated |
subscription.disable | Turndale\Paystack\Events\SubscriptionDisabled |
subscription.not_renew | Turndale\Paystack\Events\SubscriptionNotRenew |
invoice.create | Turndale\Paystack\Events\InvoiceCreated |
invoice.update | Turndale\Paystack\Events\InvoiceUpdated |
invoice.payment_failed | Turndale\Paystack\Events\InvoicePaymentFailed |
charge.dispute.create | Turndale\Paystack\Events\ChargeDisputeCreated |
transfer.success | Turndale\Paystack\Events\TransferSuccess |
transfer.failed | Turndale\Paystack\Events\TransferFailed |
Example Listener For Regular Use Case
To handle a successful payment, create a listener:
php artisan make:listener SendOrderConfirmation --event=PaymentSuccessThen, in your listener:
namespace App\Listeners;
use Turndale\Paystack\Events\PaymentSuccess;
class SendOrderConfirmation
{
public function handle(PaymentSuccess $event)
{
$payload = $event->payload;
$email = $payload['data']['customer']['email'];
$amount = $payload['data']['amount'] / 100;
// Send email to customer...
// Typical example to test
}
}Controller Architecture & Best Practices
To provide transparency and help you understand how webhooks are processed, here is the core logic of the WebhookController. The package is designed to handle Paystack's 30-second timeout limit efficiently.
<?php
namespace Turndale\Paystack\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use Turndale\Paystack\Events\WebhookReceived;
use Turndale\Paystack\Events\WebhookHandled;
use Turndale\Paystack\Events\PaymentSuccess;
use Turndale\Paystack\Events\SubscriptionDisabled;
use Turndale\Paystack\Events\SubscriptionNotRenew;
use Turndale\Paystack\Events\InvoiceCreated;
use Turndale\Paystack\Events\InvoiceUpdated;
use Turndale\Paystack\Events\InvoicePaymentFailed;
use Turndale\Paystack\Events\ChargeDisputeCreated;
use Turndale\Paystack\Events\TransferSuccess;
use Turndale\Paystack\Events\TransferFailed;
class WebhookController extends Controller
{
/**
* Handle a Paystack webhook call.
*/
public function handleWebhook(Request $request)
{
$payload = $request->all();
$event = $payload['event'] ?? null;
// 1. Immediately return 200 OK to Paystack to prevent timeouts and retries
// Use fast_finish if your server supports it, or simply return the response object.
$response = new Response('Webhook Received', 200);
// 2. Dispatch events after the response is prepared.
// Developers should use Queued Listeners to ensure this doesn't delay the response.
WebhookReceived::dispatch($payload);
if ($event) {
$method = 'handle' . Str::studly(str_replace('.', '_', $event));
if (method_exists($this, $method)) {
$this->{$method}($payload);
WebhookHandled::dispatch($payload);
}
}
return $response;
}
/**
* Helper to verify event type before dispatching.
*/
protected function validateAndDispatch(string $expected, string $eventClass, array $payload): void
{
if (($payload['event'] ?? null) === $expected) {
$eventClass::dispatch($payload);
}
}
/**
* Handle a successful charge.
*/
protected function handleChargeSuccess(array $payload)
{
$this->validateAndDispatch('charge.success', PaymentSuccess::class, $payload);
}
/**
* Handle a subscription disable event.
*/
protected function handleSubscriptionDisable(array $payload)
{
$this->validateAndDispatch('subscription.disable', SubscriptionDisabled::class, $payload);
}
/**
* Handle a subscription not renewing event.
*/
protected function handleSubscriptionNotRenew(array $payload)
{
$this->validateAndDispatch('subscription.not_renew', SubscriptionNotRenew::class, $payload);
}
// ... additional handlers for Invoice, Transfer, and Dispute events
}The Optimized Logic Flow
In the controller code above, the flow is designed for performance:
- Acknowledge: Immediately prepare the
200 OKresponse. - Broadcast Broadly:
WebhookReceived::dispatch($payload)fires first, catching everything. - Broadcast Specifically: The controller identifies the specific event (e.g.,
charge.success) and fires the corresponding class (e.g.,PaymentSuccess::dispatch($payload)). - Finish: The
200 OKresponse is sent back to Paystack, closing the connection.
Why Queued Listeners Are Recommended
As shown in the controller code, the package prepares a 200 OK response immediately:
$response = new Response('Webhook Received', 200);This is crucial because Paystack expects a response quickly. If your application takes too long to process the webhook (e.g., sending emails, generating PDFs), Paystack will timeout and retry the request, potentially leading to duplicate actions.
However, in Laravel, event listeners are synchronous by default. This means that code in your listener will run before the response is sent to Paystack.
Best Practice: Ensure your listeners are queued so the heavy logic runs in the background, allowing the controller to return the 200 OK response instantly.
class SendOrderConfirmation implements ShouldQueue
{
// Logic here runs in the background
}Use Case: Type-Safe Event Dispatching
The controller uses specific methods to validate and dispatch events. This design provides a robust use case: Type Safety.
Instead of a generic "Webhook Event" that requires you to check the type manually:
if ($event->payload['event'] === 'charge.success') { ... } // Manual checkYou can rely on the package to do this validation internally via validateAndDispatch. You simply listen for the specific event class:
public function handle(PaymentSuccess $event)
{
// You are 100% sure this is a charge.success event
}- Retry Logic: If Paystack does not receive a
200 OKresponse (due to a timeout or error), it will retry sending the webhook:- Live Mode: Retries every 3 minutes for the first 4 attempts, then hourly for the next 72 hours.
- Test Mode: Retries hourly for the next 10 hours.
- Timeout: The timeout for each attempt is 30 seconds.
Local Testing
Since Paystack cannot send webhooks directly to localhost, we recommend using Webhook.site to capture payloads and replay them locally.
Recommended Workflow Example for Local App
Get a Unique URL: Visit Webhook.site to generate a unique, temporary URL (valid for 7 days).
Update Paystack Dashboard: Copy this URL and paste it into the Webhook URL field in your Paystack Dashboard settings.
Trigger an Event: Perform an action on Paystack (e.g., make a test payment) to trigger a webhook.
Prepare A Listener for local testing -Create a listener:
php artisan make:listener TestLocalWebhook --event=PaymentSuccess- Listen & Log
- Open the listener
app/Listeners/TestLocalWebhook.phpand log :
- Open the listener
Then, in your listener:
<?php
namespace App\Listeners;
use Illuminate\Support\Facades\Log;
use Turndale\Paystack\Events\PaymentSuccess;
class TestLocalWebhook
{
public function handle(PaymentSuccess $event)
{
$payload = $event->payload;
$email = $payload['data']['customer']['email'];
$amount = $payload['data']['amount'] / 100;
Log::info("Test payload for $amount from $email");
// or you can log the whole payload
// Log::info(json_encode($payload));
}
}- Recommending to delete the logs at `app/storage/logs/laravel.log so you can easily see the new logs or CMD + F or Ctrl + F to search
- Sending Webhook Locally:
- Open Postman (Recommended).
- Select a
POSTrequest and paste your local endpoint:http://your-app.test/paystack/webhook(orhttp://localhost:8000/paystack/webhook). - On that same Postman Request page, Go to Scripts select Pre-request and paste the code below, remember to use Test Keys.
const secret = 'sk_test_xxxxxxxxxxxxxxxxx'; // Paystack SECRET key
// Get raw request body
const body = pm.request.body.raw;
// Generate HMAC SHA512
const signature = CryptoJS.HmacSHA512(body, secret).toString();
// Set header
pm.request.headers.add({
key: 'X-Paystack-Signature',
value: signature
});- Copy Payload from Webhook: Go back to Webhook.site; you should see the incoming request with the JSON payload of the event from earlier. Untick Format JSON and Word-wrap and copy the payload.
Paste the JSON into the body (select
raw->JSON).Now you can
SENDthe request to the already pasted endpointpaystack/webhookYou should see a
200Ok Status and a messageWebhook HandledmesageNow you can go check your app logs and see something like
[2025-12-31 23:50:46] local.INFO: Test payload for 50 from [email protected]- You can uncomment the last line
// Log::info(json_encode($payload));in the listener and see the full payload.
Signature Verification
For the signature verification to pass locally:
- Your local
PAYSTACK_SECRET_KEYin.envmust match the Secret Key used by Paystack and also the same one you will Paste in the PostmanPre-requestunder Scripts to sign the request (likely your Test Secret Key). - You must copy the JSON body exactly as received. Even a single extra space or newline will change the hash and cause the signature verification to fail.
This method allows you to debug your local logic without needing to set up a public tunnel.
Billable Model Updates
If you are using the Billable features of this package, the WebhookController automatically handles subscription state updates for you.
subscription.create: Updates the local subscription status toactive.subscription.disable: Marks the local subscription asdisabledand setsends_attonow().subscription.not_renew: Updates the status tonon-renewing.
You do not need to write extra logic to keep your paystack_subscriptions table in sync; the package does it automatically.
