REST API Development

Webhooks & Event-Driven APIs

20 min Lesson 24 of 35

Webhooks & Event-Driven APIs

Webhooks enable real-time, event-driven communication between systems. Instead of constantly polling an API for changes, webhooks push notifications to your application when events occur. This lesson covers webhook design, delivery mechanisms, retry logic, signature verification, and event payload structures.

What Are Webhooks?

A webhook is an HTTP callback: an HTTP POST that occurs when something happens; a simple event-notification via HTTP POST. Webhooks are "user-defined HTTP callbacks" triggered by specific events.

Webhooks vs API Polling:
  • Polling: Client repeatedly asks "Did something happen?" every few seconds/minutes
  • Webhooks: Server says "Hey, something happened!" and pushes data to the client

Webhooks are more efficient, provide real-time updates, and reduce server load.

Webhook Design Principles

1. Event Types and Naming

Define clear, consistent event types that subscribers can filter and handle.

<?php // Well-designed event naming convention class WebhookEvents { // Resource.Action pattern const USER_CREATED = 'user.created'; const USER_UPDATED = 'user.updated'; const USER_DELETED = 'user.deleted'; const ORDER_PLACED = 'order.placed'; const ORDER_CONFIRMED = 'order.confirmed'; const ORDER_SHIPPED = 'order.shipped'; const ORDER_DELIVERED = 'order.delivered'; const ORDER_CANCELLED = 'order.cancelled'; const PAYMENT_SUCCEEDED = 'payment.succeeded'; const PAYMENT_FAILED = 'payment.failed'; const PAYMENT_REFUNDED = 'payment.refunded'; const SUBSCRIPTION_CREATED = 'subscription.created'; const SUBSCRIPTION_RENEWED = 'subscription.renewed'; const SUBSCRIPTION_CANCELLED = 'subscription.cancelled'; const SUBSCRIPTION_EXPIRED = 'subscription.expired'; } // Event payload structure class WebhookPayload { public string $id; // Unique event ID public string $type; // Event type (e.g., 'order.placed') public int $created; // Timestamp public string $api_version; // API version public array $data; // Event data public ?array $previous; // Previous values (for updates) }

2. Webhook Subscription Management

Allow users to subscribe to specific events and manage their webhook endpoints.

<?php // database/migrations/create_webhooks_table.php Schema::create('webhooks', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->string('url'); // Endpoint URL $table->json('events'); // Array of subscribed event types $table->string('secret'); // Secret for signature verification $table->boolean('is_active')->default(true); $table->integer('failure_count')->default(0); $table->timestamp('last_delivered_at')->nullable(); $table->timestamp('last_failed_at')->nullable(); $table->timestamps(); $table->index(['user_id', 'is_active']); }); // app/Models/Webhook.php class Webhook extends Model { protected $fillable = [ 'user_id', 'url', 'events', 'secret', 'is_active' ]; protected $casts = [ 'events' => 'array', 'is_active' => 'boolean', 'last_delivered_at' => 'datetime', 'last_failed_at' => 'datetime' ]; public function user() { return $this->belongsTo(User::class); } public function isSubscribedTo(string $eventType): bool { return in_array($eventType, $this->events) || in_array('*', $this->events); // Wildcard subscription } public function incrementFailureCount(): void { $this->increment('failure_count'); $this->update(['last_failed_at' => now()]); // Auto-disable after 10 consecutive failures if ($this->failure_count >= 10) { $this->update(['is_active' => false]); } } public function markAsDelivered(): void { $this->update([ 'failure_count' => 0, 'last_delivered_at' => now() ]); } }

Webhook API Endpoints

<?php // app/Http/Controllers/WebhookController.php class WebhookController extends Controller { public function index(Request $request) { $webhooks = $request->user() ->webhooks() ->latest() ->get(); return WebhookResource::collection($webhooks); } public function store(Request $request) { $validated = $request->validate([ 'url' => ['required', 'url', 'active_url'], 'events' => ['required', 'array', 'min:1'], 'events.*' => ['required', 'string', 'in:' . implode(',', array_values((array) WebhookEvents::class))] ]); // Generate secret for signature verification $secret = 'whsec_' . Str::random(32); $webhook = $request->user()->webhooks()->create([ 'url' => $validated['url'], 'events' => $validated['events'], 'secret' => $secret ]); return new WebhookResource($webhook); } public function update(Request $request, Webhook $webhook) { $this->authorize('update', $webhook); $validated = $request->validate([ 'url' => ['sometimes', 'url', 'active_url'], 'events' => ['sometimes', 'array', 'min:1'], 'is_active' => ['sometimes', 'boolean'] ]); $webhook->update($validated); return new WebhookResource($webhook); } public function destroy(Webhook $webhook) { $this->authorize('delete', $webhook); $webhook->delete(); return response()->noContent(); } // Test webhook endpoint public function test(Webhook $webhook) { $this->authorize('update', $webhook); $payload = [ 'id' => 'evt_test_' . Str::random(16), 'type' => 'webhook.test', 'created' => time(), 'api_version' => 'v1', 'data' => [ 'message' => 'This is a test webhook delivery' ] ]; dispatch(new DeliverWebhook($webhook, $payload)); return response()->json([ 'message' => 'Test webhook queued for delivery' ]); } }

Webhook Delivery System

Implement a robust delivery system with queuing, retries, and failure handling.

<?php // app/Jobs/DeliverWebhook.php namespace App\Jobs; use App\Models\Webhook; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class DeliverWebhook implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $tries = 5; // Retry up to 5 times public $backoff = [60, 300, 900, 3600, 7200]; // 1m, 5m, 15m, 1h, 2h protected Webhook $webhook; protected array $payload; public function __construct(Webhook $webhook, array $payload) { $this->webhook = $webhook; $this->payload = $payload; } public function handle(): void { if (!$this->webhook->is_active) { Log::info("Webhook {$this->webhook->id} is inactive, skipping delivery"); return; } // Generate signature $signature = $this->generateSignature($this->payload); try { $response = Http::timeout(10) ->withHeaders([ 'Content-Type' => 'application/json', 'X-Webhook-Signature' => $signature, 'X-Webhook-Event' => $this->payload['type'], 'X-Webhook-Id' => $this->payload['id'], 'X-Webhook-Timestamp' => $this->payload['created'], 'User-Agent' => 'MyApp-Webhooks/1.0' ]) ->post($this->webhook->url, $this->payload); if ($response->successful()) { $this->webhook->markAsDelivered(); // Log successful delivery WebhookDelivery::create([ 'webhook_id' => $this->webhook->id, 'event_type' => $this->payload['type'], 'payload' => $this->payload, 'response_status' => $response->status(), 'response_body' => $response->body(), 'delivered_at' => now() ]); Log::info("Webhook {$this->webhook->id} delivered successfully"); } else { throw new \Exception("HTTP {$response->status()}: {$response->body()}"); } } catch (\Exception $e) { $this->webhook->incrementFailureCount(); // Log failed delivery WebhookDelivery::create([ 'webhook_id' => $this->webhook->id, 'event_type' => $this->payload['type'], 'payload' => $this->payload, 'response_status' => null, 'error_message' => $e->getMessage(), 'delivered_at' => null ]); Log::error("Webhook {$this->webhook->id} delivery failed: {$e->getMessage()}"); // Re-throw to trigger retry throw $e; } } protected function generateSignature(array $payload): string { $payloadJson = json_encode($payload); return hash_hmac('sha256', $payloadJson, $this->webhook->secret); } public function failed(\Throwable $exception): void { Log::error("Webhook {$this->webhook->id} failed permanently: {$exception->getMessage()}"); // Notify webhook owner $this->webhook->user->notify( new WebhookDeliveryFailed($this->webhook, $this->payload, $exception) ); } }

Signature Verification

Webhook receivers must verify that requests actually come from your API and haven't been tampered with.

<?php // Webhook receiver (client-side verification) class WebhookController { public function handle(Request $request) { // Get signature from headers $signature = $request->header('X-Webhook-Signature'); if (!$signature) { return response()->json(['error' => 'Missing signature'], 400); } // Get webhook secret from your database/config $secret = config('webhooks.secret'); // Calculate expected signature $payload = $request->getContent(); $expectedSignature = hash_hmac('sha256', $payload, $secret); // Constant-time comparison to prevent timing attacks if (!hash_equals($expectedSignature, $signature)) { Log::warning('Invalid webhook signature received', [ 'ip' => $request->ip(), 'headers' => $request->headers->all() ]); return response()->json(['error' => 'Invalid signature'], 401); } // Verify timestamp to prevent replay attacks $timestamp = $request->header('X-Webhook-Timestamp'); $age = time() - $timestamp; if ($age > 300) { // Reject if older than 5 minutes return response()->json(['error' => 'Webhook too old'], 400); } // Process webhook $data = $request->json()->all(); $eventType = $data['type']; match ($eventType) { 'order.placed' => $this->handleOrderPlaced($data), 'order.shipped' => $this->handleOrderShipped($data), 'payment.succeeded' => $this->handlePaymentSucceeded($data), default => Log::info("Unhandled webhook event: {$eventType}") }; // Always return 200 quickly return response()->json(['received' => true]); } protected function handleOrderPlaced(array $data): void { // Queue processing to avoid timeout ProcessOrderPlacedWebhook::dispatch($data); } }

Event Payload Structures

<?php // Consistent event payload format class WebhookPayloadBuilder { public static function build(string $eventType, Model $resource, ?Model $previous = null): array { return [ 'id' => 'evt_' . Str::random(24), 'type' => $eventType, 'created' => time(), 'api_version' => 'v1', 'data' => [ 'object' => $resource->getTable(), 'id' => $resource->id, 'attributes' => new ResourceTransformer($resource) ], 'previous' => $previous ? [ 'attributes' => new ResourceTransformer($previous) ] : null ]; } } // Example: Triggering webhooks from events class OrderObserver { public function created(Order $order): void { $payload = WebhookPayloadBuilder::build( WebhookEvents::ORDER_PLACED, $order ); $this->dispatchWebhooks($order->user, WebhookEvents::ORDER_PLACED, $payload); } public function updated(Order $order): void { if ($order->isDirty('status')) { $eventType = match ($order->status) { 'confirmed' => WebhookEvents::ORDER_CONFIRMED, 'shipped' => WebhookEvents::ORDER_SHIPPED, 'delivered' => WebhookEvents::ORDER_DELIVERED, 'cancelled' => WebhookEvents::ORDER_CANCELLED, default => null }; if ($eventType) { $previous = $order->replicate(); $previous->status = $order->getOriginal('status'); $payload = WebhookPayloadBuilder::build($eventType, $order, $previous); $this->dispatchWebhooks($order->user, $eventType, $payload); } } } protected function dispatchWebhooks(User $user, string $eventType, array $payload): void { $webhooks = Webhook::where('user_id', $user->id) ->where('is_active', true) ->get() ->filter(fn($webhook) => $webhook->isSubscribedTo($eventType)); foreach ($webhooks as $webhook) { DeliverWebhook::dispatch($webhook, $payload); } } }

Webhook Delivery Log

Track all webhook deliveries for debugging and monitoring.

<?php // database/migrations/create_webhook_deliveries_table.php Schema::create('webhook_deliveries', function (Blueprint $table) { $table->id(); $table->foreignId('webhook_id')->constrained()->onDelete('cascade'); $table->string('event_type'); $table->json('payload'); $table->integer('response_status')->nullable(); $table->text('response_body')->nullable(); $table->text('error_message')->nullable(); $table->timestamp('delivered_at')->nullable(); $table->timestamps(); $table->index(['webhook_id', 'created_at']); $table->index('event_type'); }); // API endpoint to view delivery history Route::get('/webhooks/{webhook}/deliveries', function (Webhook $webhook) { $deliveries = WebhookDelivery::where('webhook_id', $webhook->id) ->latest() ->paginate(50); return WebhookDeliveryResource::collection($deliveries); })->middleware(['auth:sanctum']);

Best Practices

Webhook Best Practices:
  • Idempotency: Design receivers to handle duplicate deliveries
  • Quick Response: Return 200 immediately, process asynchronously
  • Signature Verification: Always verify webhook authenticity
  • Retry Logic: Implement exponential backoff
  • Timeout Protection: Set reasonable timeouts (10-30 seconds)
  • Monitoring: Track delivery success rates
  • Versioning: Include API version in payloads
  • Documentation: Provide clear webhook documentation with examples
Testing Webhooks: Use tools like ngrok or RequestBin during development to expose local endpoints and inspect webhook payloads.
Exercise:
  1. Implement a complete webhook subscription system
  2. Create a signature verification middleware
  3. Build a webhook delivery job with retry logic
  4. Add webhook delivery logging and monitoring
  5. Implement idempotency keys to prevent duplicate processing
  6. Create a webhook testing endpoint for developers
Common Webhook Mistakes:
  • Not verifying signatures - allows spoofed webhooks
  • Processing synchronously - causes timeouts
  • No retry logic - loses events on temporary failures
  • Exposing sensitive data in payloads
  • Not implementing idempotency - causes duplicate processing