php-api/docs/webhook-integration.md

766 lines
19 KiB
Markdown
Raw Normal View History

# 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
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class WebhookController extends Controller
{
/**
* Handle incoming webhooks.
*/
public function handle(Request $request)
{
// Step 1: Verify the signature
if (!$this->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