Skip to content
advanced Phase 88 · Integration Advanced

Webhooks

Webhook design, payload validation, retry logic, webhook security patterns

45m
0 problems
Topic Progress 0%

Webhook Design Patterns

Webhook Architecture

┌─────────────┐     POST /webhook      ┌─────────────┐
│   Source    │ ──────────────────────► │   Receiver  │
│   System   │                         │   System    │
└─────────────┘                         └─────────────┘
       │                                      │
  Event occurs                         Process event
  Build payload                        Update state
  Sign payload                         Return 200 OK
  Send webhook                         Log receipt

Webhook Dispatcher

namespace Vendor\Webhook\Dispatcher;

class WebhookDispatcher
{
    private WebhookRepositoryInterface $webhookRepo;
    private HttpClientInterface $httpClient;
    private SignatureGeneratorInterface $signer;
    private LoggerInterface $logger;

    public function dispatch(string $eventType, array $payload): void
    {
        $webhooks = $this->webhookRepo->getByEvent($eventType);

        foreach ($webhooks as $webhook) {
            $this->sendWebhook($webhook, $eventType, $payload);
        }
    }

    private function sendWebhook(
        WebhookInterface $webhook,
        string $eventType,
        array $payload
    ): void {
        $body = json_encode([
            'event' => $eventType,
            'timestamp' => (new \DateTime())->format('c'),
            'data' => $payload,
        ]);

        $signature = $this->signer->generate(
            $body,
            $webhook->getSecret()
        );

        try {
            $this->httpClient->post($webhook->getUrl(), $body, [
                'Content-Type: application/json',
                'X-Webhook-Signature: ' . $signature,
                'X-Webhook-Event: ' . $eventType,
            ]);
        } catch (\Exception $e) {
            $this->logger->error('Webhook failed', [
                'webhook_id' => $webhook->getId(),
                'event' => $eventType,
                'error' => $e->getMessage(),
            ]);
            $this->scheduleRetry($webhook, $eventType, $payload);
        }
    }
}

Payload Validation

Webhook Receiver Validation

namespace Vendor\Webhook\Controller\Receive;

use Magento\Framework\App\Action\Action;

class WebhookReceive extends Action
{
    private SignatureVerifierInterface $verifier;
    private PayloadValidatorInterface $validator;
    private WebhookProcessorInterface $processor;

    public function execute()
    {
        $payload = file_get_contents('php://input');
        $headers = getallheaders();

        // Step 1: Verify signature
        $signature = $headers['X-Webhook-Signature'] ?? '';
        if (!$this->verifier->verify($payload, $signature)) {
            return $this->createResponse(401, 'Invalid signature');
        }

        // Step 2: Validate payload schema
        $data = json_decode($payload, true);
        $validation = $this->validator->validate($data);

        if (!$validation->isValid()) {
            return $this->createResponse(400, 'Invalid payload: ' . implode(', ', $validation->getErrors()));
        }

        // Step 3: Check idempotency
        $eventId = $data['event_id'] ?? null;
        if ($this->processor->isProcessed($eventId)) {
            return $this->createResponse(200, 'Already processed');
        }

        // Step 4: Process asynchronously
        $this->processor->process($data);

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

Schema Validation

namespace Vendor\Webhook\Validator;

class PayloadValidator implements PayloadValidatorInterface
{
    private array $schemas = [
        'order.created' => [
            'required' => ['event_id', 'event', 'data'],
            'data_required' => ['order_id', 'status', 'total'],
        ],
        'payment.captured' => [
            'required' => ['event_id', 'event', 'data'],
            'data_required' => ['transaction_id', 'amount'],
        ],
    ];

    public function validate(array $payload): ValidationResult
    {
        $eventType = $payload['event'] ?? '';
        $schema = $this->schemas[$eventType] ?? null;

        if (!$schema) {
            return new ValidationResult(false, ['Unknown event type']);
        }

        $errors = [];
        foreach ($schema['required'] as $field) {
            if (!isset($payload[$field])) {
                $errors[] = "Missing required field: $field";
            }
        }

        foreach ($schema['data_required'] as $field) {
            if (!isset($payload['data'][$field])) {
                $errors[] = "Missing required data field: $field";
            }
        }

        return new ValidationResult(empty($errors), $errors);
    }
}

Retry Logic

Retry Strategy

namespace Vendor\Webhook\Retry;

class RetryStrategy
{
    private array $retryDelays = [60, 300, 900, 3600, 7200];
    private int $maxRetries = 5;

    public function shouldRetry(WebhookLogInterface $log): bool
    {
        return $log->getAttemptCount() < $this->maxRetries;
    }

    public function getNextDelay(WebhookLogInterface $log): int
    {
        $attempt = $log->getAttemptCount();
        return $this->retryDelays[$attempt] ?? end($this->retryDelays);
    }
}

Retry Scheduler

namespace Vendor\Webhook\Retry;

class RetryScheduler
{
    private WebhookLogRepositoryInterface $logRepo;
    private RetryStrategy $retryStrategy;
    private QueueInterface $queue;

    public function scheduleFailedWebhooks(): void
    {
        $failedWebhooks = $this->logRepo->getFailed();

        foreach ($failedWebhooks as $log) {
            if ($this->retryStrategy->shouldRetry($log)) {
                $delay = $this->retryStrategy->getNextDelay($log);

                $this->queue->addMessage(new RetryWebhookMessage(
                    $log->getWebhookId(),
                    $log->getEventType(),
                    $log->getPayload()
                ), $delay);

                $log->setNextRetryAt((new \DateTime())->modify("+{$delay} seconds"));
                $this->logRepo->save($log);
            }
        }
    }
}

Dead Letter Queue

namespace Vendor\Webhook\DeadLetter;

class DeadLetterHandler
{
    private AlertInterface $alert;

    public function handle(WebhookLogInterface $log): void
    {
        // Log to dead letter queue
        $this->alert->send(
            'Webhook permanently failed',
            sprintf(
                'Webhook %d failed after %d attempts. Event: %s',
                $log->getWebhookId(),
                $log->getAttemptCount(),
                $log->getEventType()
            )
        );
    }
}

Webhook Security

Signature Generation

namespace Vendor\Webhook\Security;

class SignatureGenerator implements SignatureGeneratorInterface
{
    public function generate(string $payload, string $secret): string
    {
        return hash_hmac('sha256', $payload, $secret);
    }
}

Signature Verification

namespace Vendor\Webhook\Security;

class SignatureVerifier implements SignatureVerifierInterface
{
    public function verify(string $payload, string $signature): bool
    {
        $expected = hash_hmac('sha256', $payload, $this->secret);
        return hash_equals($expected, $signature);
    }
}

Webhook Security Headers

namespace Vendor\Webhook\Security;

class SecurityHeaders
{
    public function getHeaders(string $signature, string $eventType): array
    {
        return [
            'Content-Type' => 'application/json',
            'X-Webhook-Signature' => $signature,
            'X-Webhook-Event' => $eventType,
            'X-Webhook-Timestamp' => (new \DateTime())->format('c'),
            'User-Agent' => 'Magento-Webhook/1.0',
        ];
    }
}

IP Allowlisting

namespace Vendor\Webhook\Security;

class IpAllowlist
{
    private array $allowedIps = [
        '192.168.1.0/24',
        '10.0.0.0/8',
    ];

    public function isAllowed(string $ip): bool
    {
        foreach ($this->allowedIps as $allowed) {
            if ($this->ipInCidr($ip, $allowed)) {
                return true;
            }
        }
        return false;
    }
}

Quiz

1. Why use signature verification for webhooks?

Question 1 options

2. What is exponential backoff in retries?

Question 2 options

3. What is a dead letter queue?

Question 3 options

Flashcards

Question

What is webhook idempotency?

Answer

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

Question

How to verify webhook signatures?

Answer

HMAC-SHA256 hash comparison with hash_equals()

Question

What is retry backoff?

Answer

Increasing delays between retry attempts

Question

What is a dead letter queue?

Answer

Queue for permanently failed messages

Revision Notes

Key Takeaways

  • 1. Webhook signatures prevent spoofed requests
  • 2. Payload validation ensures correct schema
  • 3. Idempotency prevents duplicate processing
  • 4. Retry with exponential backoff handles transient failures
  • 5. Dead letter queues capture permanently failed webhooks

Interview Tips

  • Explain webhook signature verification flow
  • Discuss retry strategies with exponential backoff
  • Describe idempotency implementation
  • Talk about handling webhook security

Cheat Sheet

Webhooks:
  Dispatch → Sign → Send → Receive → Verify → Process

Security:
  HMAC-SHA256 signature
  IP allowlisting
  Timestamp validation

Retry:
  5 attempts, delays: 60s, 300s, 900s, 3600s, 7200s
  Dead letter queue for failures

Idempotency:
  event_id → processed status
  Prevents duplicate processing