# Webhook Integration Guide This guide explains how to receive and process webhooks from the core-api package. Learn to verify signatures, handle retries, and implement reliable webhook consumers. ## Overview Webhooks provide real-time notifications when events occur in the system. Instead of polling the API, your application receives HTTP POST requests with event data. **Key Features:** - HMAC-SHA256 signature verification - Automatic retries with exponential backoff - Timestamp validation for replay protection - Delivery tracking and manual retry ## Webhook Payload Format All webhooks follow a consistent format: ```json { "id": "evt_abc123def456789", "type": "post.created", "created_at": "2026-01-15T10:30:00Z", "data": { "id": 123, "title": "New Blog Post", "status": "published", "author_id": 42 }, "workspace_id": 456 } ``` **Fields:** - `id` - Unique event identifier (use for idempotency) - `type` - Event type (e.g., `post.created`, `user.updated`) - `created_at` - ISO 8601 timestamp when the event occurred - `data` - Event-specific payload - `workspace_id` - Workspace that generated the event ## Webhook Headers Every webhook request includes these headers: | Header | Description | Example | |--------|-------------|---------| | `Content-Type` | Always `application/json` | `application/json` | | `X-Webhook-Id` | Unique event ID | `evt_abc123def456` | | `X-Webhook-Event` | Event type | `post.created` | | `X-Webhook-Timestamp` | Unix timestamp | `1705312200` | | `X-Webhook-Signature` | HMAC-SHA256 signature | `a1b2c3d4e5f6...` | ## Signature Verification **Always verify webhook signatures** to ensure requests are authentic and unmodified. ### Signature Algorithm The signature is computed as: ``` signature = HMAC-SHA256(timestamp + "." + payload, secret) ``` Where: - `timestamp` is the value of `X-Webhook-Timestamp` header - `payload` is the raw request body (JSON string) - `secret` is your webhook signing secret ### Verification Steps 1. Get the signature and timestamp from headers 2. Get the raw request body (do not parse JSON first) 3. Compute expected signature: `HMAC-SHA256(timestamp + "." + body, secret)` 4. Compare signatures using timing-safe comparison 5. Verify timestamp is within 5 minutes of current time ## Code Examples ### PHP (Laravel) ```php verifySignature($request)) { Log::warning('Invalid webhook signature', [ 'ip' => $request->ip(), ]); return response()->json(['error' => 'Invalid signature'], 401); } // Step 2: Verify timestamp (replay protection) if (!$this->verifyTimestamp($request)) { Log::warning('Webhook timestamp too old'); return response()->json(['error' => 'Timestamp expired'], 401); } // Step 3: Check for duplicate events (idempotency) $eventId = $request->input('id'); if ($this->isDuplicate($eventId)) { // Already processed - return success to stop retries return response()->json(['received' => true]); } // Step 4: Process the event try { $this->processEvent( $request->input('type'), $request->input('data') ); // Mark event as processed $this->markProcessed($eventId); return response()->json(['received' => true]); } catch (\Exception $e) { Log::error('Webhook processing failed', [ 'event_id' => $eventId, 'error' => $e->getMessage(), ]); // Return 500 to trigger retry return response()->json(['error' => 'Processing failed'], 500); } } /** * Verify the HMAC-SHA256 signature. */ protected function verifySignature(Request $request): bool { $signature = $request->header('X-Webhook-Signature'); $timestamp = $request->header('X-Webhook-Timestamp'); $payload = $request->getContent(); $secret = config('services.webhooks.secret'); if (!$signature || !$timestamp) { return false; } // Compute expected signature $signedPayload = $timestamp . '.' . $payload; $expectedSignature = hash_hmac('sha256', $signedPayload, $secret); // Use timing-safe comparison return hash_equals($expectedSignature, $signature); } /** * Verify timestamp is within tolerance (5 minutes). */ protected function verifyTimestamp(Request $request): bool { $timestamp = (int) $request->header('X-Webhook-Timestamp'); $tolerance = 300; // 5 minutes return abs(time() - $timestamp) <= $tolerance; } /** * Check if event was already processed. */ protected function isDuplicate(string $eventId): bool { return cache()->has("webhook:processed:{$eventId}"); } /** * Mark event as processed (cache for 24 hours). */ protected function markProcessed(string $eventId): void { cache()->put("webhook:processed:{$eventId}", true, now()->addDay()); } /** * Process the webhook event. */ protected function processEvent(string $type, array $data): void { match ($type) { 'post.created' => $this->handlePostCreated($data), 'post.updated' => $this->handlePostUpdated($data), 'post.deleted' => $this->handlePostDeleted($data), 'user.created' => $this->handleUserCreated($data), default => Log::info("Unhandled webhook type: {$type}"), }; } protected function handlePostCreated(array $data): void { // Sync to your database, trigger notifications, etc. Log::info('Post created', $data); } protected function handlePostUpdated(array $data): void { Log::info('Post updated', $data); } protected function handlePostDeleted(array $data): void { Log::info('Post deleted', $data); } protected function handleUserCreated(array $data): void { Log::info('User created', $data); } } ``` **Route registration:** ```php // routes/api.php Route::post('/webhooks', [WebhookController::class, 'handle']) ->middleware('throttle:100,1'); // Rate limit webhook endpoint ``` ### JavaScript (Node.js/Express) ```javascript const express = require('express'); const crypto = require('crypto'); const app = express(); // Important: Use raw body for signature verification app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { const signature = req.headers['x-webhook-signature']; const timestamp = req.headers['x-webhook-timestamp']; const payload = req.body; // Raw buffer const secret = process.env.WEBHOOK_SECRET; // Step 1: Verify signature if (!verifySignature(payload, signature, timestamp, secret)) { console.warn('Invalid webhook signature'); return res.status(401).json({ error: 'Invalid signature' }); } // Step 2: Verify timestamp if (!verifyTimestamp(timestamp)) { console.warn('Webhook timestamp too old'); return res.status(401).json({ error: 'Timestamp expired' }); } // Step 3: Parse the event let event; try { event = JSON.parse(payload.toString()); } catch (e) { return res.status(400).json({ error: 'Invalid JSON' }); } // Step 4: Check for duplicates if (await isDuplicate(event.id)) { return res.json({ received: true }); } // Step 5: Process the event try { await processEvent(event.type, event.data); await markProcessed(event.id); res.json({ received: true }); } catch (e) { console.error('Webhook processing failed:', e); res.status(500).json({ error: 'Processing failed' }); } }); function verifySignature(payload, signature, timestamp, secret) { if (!signature || !timestamp) return false; const signedPayload = timestamp + '.' + payload.toString(); const expectedSignature = crypto .createHmac('sha256', secret) .update(signedPayload) .digest('hex'); // Timing-safe comparison try { return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } catch { return false; } } function verifyTimestamp(timestamp) { const tolerance = 300; // 5 minutes const now = Math.floor(Date.now() / 1000); return Math.abs(now - parseInt(timestamp)) <= tolerance; } // Redis-based duplicate detection const Redis = require('ioredis'); const redis = new Redis(); async function isDuplicate(eventId) { return await redis.exists(`webhook:processed:${eventId}`); } async function markProcessed(eventId) { await redis.set(`webhook:processed:${eventId}`, '1', 'EX', 86400); } async function processEvent(type, data) { switch (type) { case 'post.created': console.log('Post created:', data); break; case 'post.updated': console.log('Post updated:', data); break; case 'post.deleted': console.log('Post deleted:', data); break; default: console.log(`Unhandled event type: ${type}`); } } app.listen(3000); ``` ### Python (Flask) ```python import hmac import hashlib import time import json from functools import wraps from flask import Flask, request, jsonify import redis app = Flask(__name__) cache = redis.Redis() WEBHOOK_SECRET = 'your_webhook_secret' TIMESTAMP_TOLERANCE = 300 # 5 minutes def verify_webhook(f): """Decorator to verify webhook signatures.""" @wraps(f) def decorated(*args, **kwargs): signature = request.headers.get('X-Webhook-Signature') timestamp = request.headers.get('X-Webhook-Timestamp') payload = request.get_data() # Verify signature if not verify_signature(payload, signature, timestamp): return jsonify({'error': 'Invalid signature'}), 401 # Verify timestamp if not verify_timestamp(timestamp): return jsonify({'error': 'Timestamp expired'}), 401 return f(*args, **kwargs) return decorated def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool: """Verify the HMAC-SHA256 signature.""" if not signature or not timestamp: return False signed_payload = f"{timestamp}.{payload.decode('utf-8')}" expected_signature = hmac.new( WEBHOOK_SECRET.encode('utf-8'), signed_payload.encode('utf-8'), hashlib.sha256 ).hexdigest() # Timing-safe comparison return hmac.compare_digest(expected_signature, signature) def verify_timestamp(timestamp: str) -> bool: """Verify timestamp is within tolerance.""" try: ts = int(timestamp) return abs(time.time() - ts) <= TIMESTAMP_TOLERANCE except (ValueError, TypeError): return False def is_duplicate(event_id: str) -> bool: """Check if event was already processed.""" return cache.exists(f"webhook:processed:{event_id}") def mark_processed(event_id: str) -> None: """Mark event as processed (24 hour TTL).""" cache.setex(f"webhook:processed:{event_id}", 86400, "1") @app.route('/webhooks', methods=['POST']) @verify_webhook def handle_webhook(): event = request.get_json() event_id = event.get('id') event_type = event.get('type') data = event.get('data') # Check for duplicates if is_duplicate(event_id): return jsonify({'received': True}) # Process the event try: process_event(event_type, data) mark_processed(event_id) return jsonify({'received': True}) except Exception as e: app.logger.error(f"Webhook processing failed: {e}") return jsonify({'error': 'Processing failed'}), 500 def process_event(event_type: str, data: dict) -> None: """Process webhook event based on type.""" handlers = { 'post.created': handle_post_created, 'post.updated': handle_post_updated, 'post.deleted': handle_post_deleted, 'user.created': handle_user_created, } handler = handlers.get(event_type) if handler: handler(data) else: app.logger.info(f"Unhandled event type: {event_type}") def handle_post_created(data: dict) -> None: app.logger.info(f"Post created: {data}") def handle_post_updated(data: dict) -> None: app.logger.info(f"Post updated: {data}") def handle_post_deleted(data: dict) -> None: app.logger.info(f"Post deleted: {data}") def handle_user_created(data: dict) -> None: app.logger.info(f"User created: {data}") if __name__ == '__main__': app.run(port=3000) ``` ## Retry Handling ### Retry Schedule Failed webhook deliveries are automatically retried with exponential backoff: | Attempt | Delay | Total Time | |---------|-------|------------| | 1 | Immediate | 0 | | 2 | 1 minute | 1 minute | | 3 | 5 minutes | 6 minutes | | 4 | 30 minutes | 36 minutes | | 5 | 2 hours | 2h 36m | | 6 (final) | 24 hours | 26h 36m | After 6 failed attempts, the delivery is marked as permanently failed. ### Triggering Retries A delivery is retried when your endpoint returns: - **5xx status codes** (server errors) - **Connection timeouts** (30 second default) - **Connection refused/failed** A delivery is **not** retried when: - **2xx status codes** (success) - **4xx status codes** (client errors - your endpoint rejected it) ### Best Practices for Reliability **1. Return 200 Quickly** Process webhooks asynchronously to avoid timeouts: ```php public function handle(Request $request) { // Verify signature first if (!$this->verifySignature($request)) { return response()->json(['error' => 'Invalid signature'], 401); } // Queue for async processing ProcessWebhook::dispatch($request->all()); // Return immediately return response()->json(['received' => true]); } ``` **2. Handle Duplicates** Webhooks may be delivered more than once. Always check the event ID: ```php public function handle(Request $request) { $eventId = $request->input('id'); // Atomic check-and-set if (!Cache::add("webhook:{$eventId}", true, now()->addDay())) { // Already processed return response()->json(['received' => true]); } // Process the event... } ``` **3. Return 4xx for Permanent Failures** If your endpoint cannot process an event (invalid data, etc.), return 4xx to stop retries: ```php public function handle(Request $request) { $eventType = $request->input('type'); // Unknown event type - don't retry if (!in_array($eventType, $this->supportedEvents)) { return response()->json(['error' => 'Unknown event type'], 400); } // Process... } ``` ## Event Types ### Common Events | Event | Description | |-------|-------------| | `{resource}.created` | Resource was created | | `{resource}.updated` | Resource was updated | | `{resource}.deleted` | Resource was deleted | | `{resource}.published` | Resource was published | | `{resource}.archived` | Resource was archived | ### Wildcard Subscriptions Subscribe to all events for a resource: ```php $webhook = WebhookEndpoint::create([ 'url' => 'https://your-app.com/webhooks', 'events' => ['post.*'], // All post events 'secret' => 'whsec_' . Str::random(32), ]); ``` Subscribe to all events: ```php $webhook = WebhookEndpoint::create([ 'url' => 'https://your-app.com/webhooks', 'events' => ['*'], // All events 'secret' => 'whsec_' . Str::random(32), ]); ``` ### High-Volume Events Some events are high-volume and opt-in only: - `link.clicked` - Link click tracking - `qrcode.scanned` - QR code scan tracking These must be explicitly included in the `events` array. ## Testing Webhooks ### Test Endpoint Use the test endpoint to verify your webhook handler: ```bash curl -X POST https://api.example.com/v1/webhooks/{webhook_id}/test \ -H "Authorization: Bearer sk_live_abc123" ``` This sends a test event to your endpoint. ### Local Development For local development, use a tunnel service: **ngrok:** ```bash ngrok http 3000 # Use the https URL as your webhook endpoint ``` **Cloudflare Tunnel:** ```bash cloudflared tunnel --url http://localhost:3000 ``` ### Mock Verification Test signature verification in isolation: ```php // tests/Feature/WebhookTest.php public function test_verifies_valid_signature(): void { $payload = json_encode([ 'id' => 'evt_test123', 'type' => 'post.created', 'data' => ['id' => 1, 'title' => 'Test'], ]); $timestamp = time(); $secret = 'test_secret'; $signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret); config(['services.webhooks.secret' => $secret]); $response = $this->postJson('/webhooks', json_decode($payload, true), [ 'X-Webhook-Signature' => $signature, 'X-Webhook-Timestamp' => $timestamp, 'Content-Type' => 'application/json', ]); $response->assertOk(); } public function test_rejects_invalid_signature(): void { $response = $this->postJson('/webhooks', [ 'id' => 'evt_test123', 'type' => 'post.created', ], [ 'X-Webhook-Signature' => 'invalid', 'X-Webhook-Timestamp' => time(), ]); $response->assertUnauthorized(); } ``` ## Troubleshooting ### Signature Verification Fails **Common causes:** 1. **Parsed JSON instead of raw body** ```php // Wrong - body has been modified $payload = json_encode($request->all()); // Correct - raw body $payload = $request->getContent(); ``` 2. **Different secrets** - Check the secret matches exactly - Ensure no extra whitespace 3. **Encoding issues** ```php // Ensure UTF-8 encoding $payload = $request->getContent(); $signedPayload = $timestamp . '.' . $payload; ``` ### Deliveries Not Arriving 1. **Check endpoint URL** - Must be publicly accessible (not localhost) 2. **Check SSL certificate** - Must be valid and not expired 3. **Check firewall rules** - Allow incoming HTTPS from webhook IPs 4. **Check webhook is active** - Endpoints can be disabled after failures ### Timeouts The default timeout is 30 seconds. If processing takes longer: ```php // Queue long-running tasks public function handle(Request $request) { // Quick signature check if (!$this->verifySignature($request)) { return response()->json(['error' => 'Invalid signature'], 401); } // Queue for async processing ProcessWebhook::dispatch($request->all()); // Return immediately return response()->json(['received' => true]); } ``` ## Security Considerations ### Always Verify Signatures Never skip signature verification, even in development: ```php // DON'T DO THIS if (app()->environment('local')) { return; // Skip verification } ``` ### Use HTTPS Webhook endpoints must use HTTPS to protect: - The webhook secret in transit - Sensitive payload data ### Protect Your Secret - Store in environment variables, not code - Rotate secrets periodically - Use different secrets per environment ### Rate Limit Your Endpoint Protect against abuse: ```php Route::post('/webhooks', [WebhookController::class, 'handle']) ->middleware('throttle:100,1'); // 100 requests per minute ``` ## Learn More - [Webhooks Overview](/packages/api/webhooks) - Creating webhook endpoints - [Authentication](/packages/api/authentication) - API key management - [Rate Limiting](/packages/api/rate-limiting) - Understanding rate limits