Skip to content

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:

php
// 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.

  1. Log in to your Paystack Dashboard.
  2. Go to Settings > API Keys & Webhooks.
  3. Enter your URL in the Webhook URL field.
  4. 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.

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:

  1. Validates the x-paystack-signature header.
  2. Confirms the signature matches your PAYSTACK_SECRET_KEY.
  3. Rejects any request that fails validation with a 403 Forbidden response.

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 ClassPaystack EventDescription
Turndale\Paystack\Events\PaymentSuccesscharge.successA payment was successful.
Turndale\Paystack\Events\SubscriptionCreatedsubscription.createA new subscription was created.
Turndale\Paystack\Events\SubscriptionDisabledsubscription.disableA subscription was disabled (payment failed/cancelled).
Turndale\Paystack\Events\SubscriptionNotRenewsubscription.not_renewA subscription is set to not renew.
Turndale\Paystack\Events\InvoiceCreatedinvoice.createAn invoice was generated.
Turndale\Paystack\Events\InvoiceUpdatedinvoice.updateAn invoice was updated.
Turndale\Paystack\Events\InvoicePaymentFailedinvoice.payment_failedAn invoice payment failed.
Turndale\Paystack\Events\ChargeDisputeCreatedcharge.dispute.createA charge dispute was created.
Turndale\Paystack\Events\TransferSuccesstransfer.successA transfer was completed.
Turndale\Paystack\Events\TransferFailedtransfer.failedA transfer failed.
Turndale\Paystack\Events\WebhookReceivedAllDispatched for every webhook before specific handling.
Turndale\Paystack\Events\WebhookHandledSpecificDispatched 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 StringPackage Event Class
Any EventTurndale\Paystack\Events\WebhookReceived
charge.successTurndale\Paystack\Events\PaymentSuccess
subscription.createTurndale\Paystack\Events\SubscriptionCreated
subscription.disableTurndale\Paystack\Events\SubscriptionDisabled
subscription.not_renewTurndale\Paystack\Events\SubscriptionNotRenew
invoice.createTurndale\Paystack\Events\InvoiceCreated
invoice.updateTurndale\Paystack\Events\InvoiceUpdated
invoice.payment_failedTurndale\Paystack\Events\InvoicePaymentFailed
charge.dispute.createTurndale\Paystack\Events\ChargeDisputeCreated
transfer.successTurndale\Paystack\Events\TransferSuccess
transfer.failedTurndale\Paystack\Events\TransferFailed

Example Listener For Regular Use Case

To handle a successful payment, create a listener:

bash
php artisan make:listener SendOrderConfirmation --event=PaymentSuccess

Then, in your listener:

php
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
<?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:

  1. Acknowledge: Immediately prepare the 200 OK response.
  2. Broadcast Broadly: WebhookReceived::dispatch($payload) fires first, catching everything.
  3. Broadcast Specifically: The controller identifies the specific event (e.g., charge.success) and fires the corresponding class (e.g., PaymentSuccess::dispatch($payload)).
  4. Finish: The 200 OK response is sent back to Paystack, closing the connection.

As shown in the controller code, the package prepares a 200 OK response immediately:

php
$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.

php
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:

php
if ($event->payload['event'] === 'charge.success') { ... } // Manual check

You can rely on the package to do this validation internally via validateAndDispatch. You simply listen for the specific event class:

php
public function handle(PaymentSuccess $event)
{
    // You are 100% sure this is a charge.success event
}
  • Retry Logic: If Paystack does not receive a 200 OK response (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.

  1. Get a Unique URL: Visit Webhook.site to generate a unique, temporary URL (valid for 7 days).

  2. Update Paystack Dashboard: Copy this URL and paste it into the Webhook URL field in your Paystack Dashboard settings.

  3. Trigger an Event: Perform an action on Paystack (e.g., make a test payment) to trigger a webhook.

  4. Prepare A Listener for local testing -Create a listener:

bash
php artisan make:listener TestLocalWebhook --event=PaymentSuccess
  1. Listen & Log
    • Open the listener app/Listeners/TestLocalWebhook.php and log :

Then, in your listener:

php

<?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
  1. Sending Webhook Locally:
    • Open Postman (Recommended).
    • Select a POST request and paste your local endpoint: http://your-app.test/paystack/webhook (or http://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.
js
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
});
  1. 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 SEND the request to the already pasted endpoint paystack/webhook

  • You should see a 200 Ok Status and a message Webhook Handled mesage

  • Now you can go check your app logs and see something like

html

[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:

  1. Your local PAYSTACK_SECRET_KEY in .env must match the Secret Key used by Paystack and also the same one you will Paste in the Postman Pre-request under Scripts to sign the request (likely your Test Secret Key).
  2. 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 to active.
  • subscription.disable: Marks the local subscription as disabled and sets ends_at to now().
  • subscription.not_renew: Updates the status to non-renewing.

You do not need to write extra logic to keep your paystack_subscriptions table in sync; the package does it automatically.

Released under the MIT License.