Bonaventure OgetoBy Bonaventure Ogeto|

Handle Paystack Webhooks in Symfony

To handle Paystack webhooks in Symfony, 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 the event for processing. Return a 200 response.

Webhook Controller

// src/Controller/WebhookController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class WebhookController extends AbstractController
{
    #[Route('/webhooks/paystack', methods: ['POST'])]
    public function handlePaystack(Request $request): Response
    {
        $signature = $request->headers->get('X-Paystack-Signature', '');

        if (!$signature) {
            return new Response('Missing signature', 400);
        }

        $body = $request->getContent();
        $secret = $this->getParameter('paystack_secret_key');

        $computedHash = hash_hmac('sha512', $body, $secret);

        if (!hash_equals($computedHash, $signature)) {
            return new Response('Invalid signature', 400);
        }

        $event = json_decode($body, true);

        // Process the event
        $this->processEvent($event);

        return new Response('OK', 200);
    }

    private function processEvent(array $event): void
    {
        $eventType = $event['event'] ?? '';
        $data = $event['data'] ?? [];

        switch ($eventType) {
            case 'charge.success':
                $reference = $data['reference'] ?? null;
                if ($reference) {
                    // Call verifyAndFulfill
                }
                break;
            case 'subscription.create':
                // Handle new subscription
                break;
            case 'invoice.payment_failed':
                // Handle failed renewal
                break;
        }
    }
}

Async Processing with Messenger

// src/Message/PaystackWebhookMessage.php
namespace App\Message;

class PaystackWebhookMessage
{
    private array $event;

    public function __construct(array $event)
    {
        $this->event = $event;
    }

    public function getEvent(): array
    {
        return $this->event;
    }
}

// In the controller:
use Symfony\Component\Messenger\MessageBusInterface;

$this->bus->dispatch(new PaystackWebhookMessage($event));
return new Response('OK', 200);

Testing

public function testValidWebhook(): void
{
    $payload = json_encode(['event' => 'charge.success', 'data' => ['reference' => 'test']]);
    $signature = hash_hmac('sha512', $payload, 'sk_test_xxx');

    $this->client->request('POST', '/webhooks/paystack', [], [], [
        'HTTP_X_PAYSTACK_SIGNATURE' => $signature,
        'CONTENT_TYPE' => 'application/json',
    ], $payload);

    $this->assertResponseStatusCodeSame(200);
}

Ship Payments Faster

Key Takeaways

  • Use $request->getContent() for the raw body bytes needed for HMAC verification.
  • Use hash_hmac("sha512", $body, $secret) and hash_equals() for secure comparison.
  • Symfony Messenger component works well for async webhook processing.
  • Return Response with status 200 immediately before processing.
  • Make handlers idempotent. Paystack retries failed deliveries.
  • Symfony does not have global CSRF on controller actions, so no exemption needed.

Frequently Asked Questions

Does Symfony need CSRF exemption for webhooks?
No. Symfony does not apply CSRF protection to plain controller actions. CSRF is only used with Symfony Forms. Webhook endpoints work without any special configuration.
Should I use Messenger or Events for webhook processing?
Messenger is better for async processing with a queue transport. Events work for synchronous processing. For webhooks, Messenger with an async transport ensures fast responses.
How do I test webhooks locally?
Use ngrok to expose your Symfony dev server. Or compute the HMAC in your test and send it in the request header using the Symfony test client.

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