Skip to content
advanced Phase 89 · Payment Deep Dive

Payment Webhooks

Payment webhooks - callback handling, notification verification, async payment updates

45m
0 problems
Topic Progress 0%

Payment Webhook Architecture

Payment Webhook Flow

  Payment Gateway          Magento 2 Store
       │                         │
  1. Payment event occurs        │
  2. Send webhook                │
       ├────────────────────────►│
       │  3. Verify signature     │
       │  4. Parse event          │
       │  5. Update order         │
       │  6. Return 200 OK        │
       │◄────────────────────────┤
       │                         │

Webhook Controller

namespace Vendor\Payment\Controller\Webhook;

use Magento\Framework\App\Action\Action;

class StripeWebhook extends Action
{
    private WebhookVerifierInterface $verifier;
    private WebhookDispatcherInterface $dispatcher;
    private JsonFactory $jsonFactory;

    public function execute()
    {
        $payload = file_get_contents('php://input');
        $signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

        // Verify signature
        if (!$this->verifier->verify($payload, $signature)) {
            return $this->createResponse(401, 'Invalid signature');
        }

        $event = json_decode($payload, true);

        // Dispatch to handler
        $this->dispatcher->dispatch(
            $event['type'],
            $event['data']['object'] ?? [],
            $event['id']
        );

        return $this->createResponse(200, 'OK');
    }
}

Webhook Event Handlers

Event Handler Registry

namespace Vendor\Payment\Webhook\Handler;

interface WebhookHandlerInterface
{
    public function handle(array $data, string $eventId): void;
    public function getEventType(): string;
}

class PaymentSucceededHandler implements WebhookHandlerInterface
{
    private OrderRepositoryInterface $orderRepo;
    private TransactionRepositoryInterface $transactionRepo;

    public function handle(array $data, string $eventId): void
    {
        $order = $this->orderRepo->getByIncrementId($data['metadata']['order_id']);

        // Create transaction
        $transaction = new TransactionData([
            'order_id' => $order->getId(),
            'transaction_id' => $data['payment_intent'],
            'type' => 'capture',
            'amount' => $data['amount'] / 100,
            'status' => 'approved',
        ]);
        $this->transactionRepo->save($transaction);

        // Update order status
        $order->setState(Order::STATE_PROCESSING);
        $order->addCommentToStatusHistory('Payment received via webhook');
        $this->orderRepo->save($order);
    }

    public function getEventType(): string
    {
        return 'payment_intent.succeeded';
    }
}

class PaymentFailedHandler implements WebhookHandlerInterface
{
    public function handle(array $data, string $eventId): void
    {
        $order = $this->orderRepo->getByIncrementId($data['metadata']['order_id']);

        $order->setState(Order::STATE_CANCELED);
        $order->addCommentToStatusHistory(
            'Payment failed: ' . ($data['last_payment_error']['message'] ?? 'Unknown error')
        );
        $this->orderRepo->save($order);
    }

    public function getEventType(): string
    {
        return 'payment_intent.payment_failed';
    }
}

Handler Dispatcher

namespace Vendor\Payment\Webhook\Handler;

class WebhookDispatcher implements WebhookDispatcherInterface
{
    private array $handlers;
    private IdempotencyCheckerInterface $idempotency;
    private LoggerInterface $logger;

    public function dispatch(string $eventType, array $data, string $eventId): void
    {
        // Check idempotency
        if ($this->idempotency->isProcessed($eventId)) {
            $this->logger->info('Duplicate webhook ignored', ['event_id' => $eventId]);
            return;
        }

        $handler = $this->handlers[$eventType] ?? null;
        if (!$handler) {
            $this->logger->warning('No handler for event type', ['type' => $eventType]);
            return;
        }

        try {
            $handler->handle($data, $eventId);
            $this->idempotency->markProcessed($eventId);
        } catch (\Exception $e) {
            $this->logger->error('Webhook handler failed', [
                'event_type' => $eventType,
                'event_id' => $eventId,
                'error' => $e->getMessage(),
            ]);
            throw $e;
        }
    }
}

Notification Verification

PayPal IPN Verification

namespace Vendor\Payment\Webhook\Verify;

class PayPalIpnVerifier implements NotificationVerifierInterface
{
    private HttpClientInterface $httpClient;

    public function verify(array $ipnData): bool
    {
        // Step 1: Build verification request
        $verificationData = 'cmd=_notify-validate';
        foreach ($ipnData as $key => $value) {
            $verificationData .= '&' . $key . '=' . urlencode($value);
        }

        // Step 2: Send to PayPal for verification
        $response = $this->httpClient->post(
            'https://www.paypal.com/cgi-bin/webscr',
            $verificationData,
            ['Content-Type' => 'application/x-www-form-urlencoded']
        );

        // Step 3: Check response
        return trim($response->getBody()) === 'VERIFIED';
    }
}

Webhook Signature Verification

namespace Vendor\Payment\Webhook\Verify;

class StripeSignatureVerifier implements NotificationVerifierInterface
{
    private string $secret;

    public function verify(string $payload, string $signatureHeader): bool
    {
        $elements = [];
        foreach (explode(',', $signatureHeader) as $pair) {
            [$key, $value] = explode('=', $pair, 2);
            $elements[$key] = $value;
        }

        $timestamp = $elements['t'] ?? '';
        $expectedSig = $elements['v1'] ?? '';

        // Reject old payloads (replay protection)
        $age = time() - (int) $timestamp;
        if ($age > 300) {
            return false;
        }

        $signedPayload = $timestamp . '.' . $payload;
        $computedSig = hash_hmac('sha256', $signedPayload, $this->secret);

        return hash_equals($expectedSig, $computedSig);
    }
}

Async Payment Updates

Async Status Sync

namespace Vendor\Payment\Webhook\Sync;

class PaymentStatusSync
{
    private QueueInterface $queue;

    public function scheduleSync(
        string $orderId,
        string $status,
        array $data
    ): void {
        $this->queue->addMessage(new PaymentStatusMessage(
            $orderId,
            $status,
            $data
        ));
    }
}

namespace Vendor\Payment\Webhook\Sync\Handler;

class PaymentStatusHandler
{
    public function process(PaymentStatusMessage $message): void
    {
        $order = $this->orderRepo->getByIncrementId($message->getOrderId());

        match ($message->getStatus()) {
            'succeeded' => $this->handleSucceeded($order, $message->getData()),
            'failed' => $this->handleFailed($order, $message->getData()),
            'refunded' => $this->handleRefunded($order, $message->getData()),
            'disputed' => $this->handleDisputed($order, $message->getData()),
        };
    }
}

Webhook Log

namespace Vendor\Payment\Webhook\Log;

class WebhookLogger
{
    private WebhookLogRepositoryInterface $logRepo;

    public function log(
        string $gateway,
        string $eventType,
        array $payload,
        string $status
    ): void {
        $log = new WebhookLogData([
            'gateway' => $gateway,
            'event_type' => $eventType,
            'payload' => json_encode($payload),
            'status' => $status,
            'received_at' => new \DateTime(),
        ]);

        $this->logRepo->save($log);
    }
}

Quiz

1. Why verify payment webhook signatures?

Question 1 options

2. What is idempotency in webhooks?

Question 2 options

3. How does PayPal IPN verification work?

Question 3 options

Flashcards

Question

What is payment webhook idempotency?

Answer

Processing the same event only once, even if received multiple times

Question

How to verify Stripe webhooks?

Answer

HMAC-SHA256 signature verification with timestamp

Question

How does PayPal IPN verify?

Answer

Post data back to PayPal, they return VERIFIED/INVALID

Question

What does a webhook handler do?

Answer

Processes payment events and updates order status

Revision Notes

Key Takeaways

  • 1. Payment webhooks handle async status updates from gateways
  • 2. Signature verification prevents spoofed webhook requests
  • 3. Idempotency prevents duplicate processing of events
  • 4. Event handlers map gateway events to order operations
  • 5. Webhook logs provide audit trail for payment events

Interview Tips

  • Explain how webhook signature verification works
  • Discuss idempotency implementation for payment events
  • Describe handling async payment status updates
  • Talk about PayPal IPN vs Stripe webhooks

Cheat Sheet

Payment Webhooks:
  Gateway → verify → dispatch → handler → order update

Verification:
  Stripe: HMAC-SHA256 signature
  PayPal: IPN verification post

Events:
  payment_intent.succeeded → complete order
  payment_intent.payment_failed → cancel order
  charge.refunded → process refund

Idempotency:
  event_id → processed status
  Prevents duplicate processing