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?
2. What is exponential backoff in retries?
3. What is a dead letter queue?
Flashcards
Question
What is webhook idempotency?
Click to reveal answer
Answer
Processing the same event only once, even if received multiple times
Question
How to verify webhook signatures?
Click to reveal answer
Answer
HMAC-SHA256 hash comparison with hash_equals()
Question
What is retry backoff?
Click to reveal answer
Answer
Increasing delays between retry attempts
Question
What is a dead letter queue?
Click to reveal answer
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