- Add 100 blog posts covering AI, development, and tech topics - Add .env.example for environment configuration - Add accessibility and lighthouse audit scripts - Remove obsolete SEO reports and temporary files - Remove dev-dist build artifacts and backup files - Remove unused portrait images (moved/consolidated elsewhere) - Update contact form and component improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
656 lines
19 KiB
Markdown
656 lines
19 KiB
Markdown
# Webhook Implementation
|
|
|
|
**Meta-Description:** Webhooks implementieren für Event-Driven Architecture. Signierung, Retry-Logic und Webhook Management für SaaS.
|
|
|
|
**Keywords:** Webhooks, Event-Driven, Signature Verification, Retry Logic, Webhook Delivery, Integration, API Events
|
|
|
|
---
|
|
|
|
## Einführung
|
|
|
|
**Webhooks** ermöglichen Echtzeit-Benachrichtigungen bei Events. Statt Polling erhalten Clients Push-Notifications. Dieser Guide zeigt sichere, zuverlässige Webhook-Implementation mit Retry-Logic und Signaturverifikation.
|
|
|
|
---
|
|
|
|
## Webhook Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ WEBHOOK DELIVERY SYSTEM │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ Event Flow: │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ 1. Event occurs in application │ │
|
|
│ │ ↓ │ │
|
|
│ │ 2. Event published to queue │ │
|
|
│ │ ↓ │ │
|
|
│ │ 3. Webhook worker picks up event │ │
|
|
│ │ ↓ │ │
|
|
│ │ 4. Look up subscribed endpoints │ │
|
|
│ │ ↓ │ │
|
|
│ │ 5. Sign and send payload │ │
|
|
│ │ ↓ │ │
|
|
│ │ 6. Handle response / retry on failure │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ Webhook Payload: │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ { │ │
|
|
│ │ "id": "evt_123", │ │
|
|
│ │ "type": "project.created", │ │
|
|
│ │ "timestamp": "2024-01-15T10:30:00Z", │ │
|
|
│ │ "data": { │ │
|
|
│ │ "id": "proj_456", │ │
|
|
│ │ "name": "New Project" │ │
|
|
│ │ } │ │
|
|
│ │ } │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ Security: │
|
|
│ ├── HMAC-SHA256 signature in header │
|
|
│ ├── Timestamp to prevent replay attacks │
|
|
│ ├── HTTPS only │
|
|
│ └── IP allowlisting (optional) │
|
|
│ │
|
|
│ Retry Strategy: │
|
|
│ ├── Attempt 1: Immediate │
|
|
│ ├── Attempt 2: After 1 minute │
|
|
│ ├── Attempt 3: After 5 minutes │
|
|
│ ├── Attempt 4: After 30 minutes │
|
|
│ └── Attempt 5: After 2 hours │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Webhook Configuration
|
|
|
|
```typescript
|
|
// lib/webhooks/types.ts
|
|
export interface WebhookEndpoint {
|
|
id: string;
|
|
tenantId: string;
|
|
url: string;
|
|
secret: string;
|
|
events: string[];
|
|
active: boolean;
|
|
createdAt: Date;
|
|
metadata?: Record<string, string>;
|
|
}
|
|
|
|
export interface WebhookEvent {
|
|
id: string;
|
|
type: string;
|
|
timestamp: Date;
|
|
data: unknown;
|
|
tenantId: string;
|
|
}
|
|
|
|
export interface WebhookDelivery {
|
|
id: string;
|
|
endpointId: string;
|
|
eventId: string;
|
|
status: 'pending' | 'success' | 'failed';
|
|
attempts: number;
|
|
lastAttemptAt?: Date;
|
|
nextAttemptAt?: Date;
|
|
response?: {
|
|
statusCode: number;
|
|
body?: string;
|
|
duration: number;
|
|
};
|
|
}
|
|
|
|
// Event types
|
|
export const WEBHOOK_EVENTS = {
|
|
// Project events
|
|
'project.created': 'Project was created',
|
|
'project.updated': 'Project was updated',
|
|
'project.deleted': 'Project was deleted',
|
|
|
|
// User events
|
|
'user.created': 'User was created',
|
|
'user.updated': 'User was updated',
|
|
'user.deleted': 'User was deleted',
|
|
|
|
// Subscription events
|
|
'subscription.created': 'Subscription was created',
|
|
'subscription.updated': 'Subscription was updated',
|
|
'subscription.canceled': 'Subscription was canceled',
|
|
|
|
// Invoice events
|
|
'invoice.created': 'Invoice was created',
|
|
'invoice.paid': 'Invoice was paid',
|
|
'invoice.failed': 'Invoice payment failed'
|
|
} as const;
|
|
|
|
export type WebhookEventType = keyof typeof WEBHOOK_EVENTS;
|
|
```
|
|
|
|
---
|
|
|
|
## Signature & Verification
|
|
|
|
```typescript
|
|
// lib/webhooks/signature.ts
|
|
import { createHmac, timingSafeEqual } from 'crypto';
|
|
|
|
const SIGNATURE_HEADER = 'X-Webhook-Signature';
|
|
const TIMESTAMP_HEADER = 'X-Webhook-Timestamp';
|
|
const TOLERANCE_SECONDS = 300; // 5 minutes
|
|
|
|
export function signWebhookPayload(
|
|
payload: string,
|
|
secret: string,
|
|
timestamp: number
|
|
): string {
|
|
const signedPayload = `${timestamp}.${payload}`;
|
|
const signature = createHmac('sha256', secret)
|
|
.update(signedPayload)
|
|
.digest('hex');
|
|
|
|
return `v1=${signature}`;
|
|
}
|
|
|
|
export function verifyWebhookSignature(
|
|
payload: string,
|
|
signature: string,
|
|
secret: string,
|
|
timestamp: number
|
|
): boolean {
|
|
// Check timestamp tolerance
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
|
|
throw new Error('Webhook timestamp outside tolerance window');
|
|
}
|
|
|
|
// Extract signature version and value
|
|
const [version, receivedSig] = signature.split('=');
|
|
if (version !== 'v1') {
|
|
throw new Error('Unsupported signature version');
|
|
}
|
|
|
|
// Calculate expected signature
|
|
const signedPayload = `${timestamp}.${payload}`;
|
|
const expectedSig = createHmac('sha256', secret)
|
|
.update(signedPayload)
|
|
.digest('hex');
|
|
|
|
// Constant-time comparison
|
|
const receivedBuffer = Buffer.from(receivedSig, 'hex');
|
|
const expectedBuffer = Buffer.from(expectedSig, 'hex');
|
|
|
|
if (receivedBuffer.length !== expectedBuffer.length) {
|
|
return false;
|
|
}
|
|
|
|
return timingSafeEqual(receivedBuffer, expectedBuffer);
|
|
}
|
|
|
|
// Generate webhook secret
|
|
export function generateWebhookSecret(): string {
|
|
return `whsec_${randomBytes(32).toString('hex')}`;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Event Publishing
|
|
|
|
```typescript
|
|
// lib/webhooks/publisher.ts
|
|
import { db } from '@/lib/db';
|
|
import { redis } from '@/lib/redis';
|
|
import { WebhookEvent, WebhookEventType } from './types';
|
|
import { nanoid } from 'nanoid';
|
|
|
|
export async function publishWebhookEvent(
|
|
type: WebhookEventType,
|
|
tenantId: string,
|
|
data: unknown
|
|
): Promise<string> {
|
|
const event: WebhookEvent = {
|
|
id: `evt_${nanoid()}`,
|
|
type,
|
|
timestamp: new Date(),
|
|
data,
|
|
tenantId
|
|
};
|
|
|
|
// Store event
|
|
await db.webhookEvent.create({
|
|
data: {
|
|
id: event.id,
|
|
type: event.type,
|
|
tenantId: event.tenantId,
|
|
data: event.data as any,
|
|
createdAt: event.timestamp
|
|
}
|
|
});
|
|
|
|
// Get subscribed endpoints
|
|
const endpoints = await db.webhookEndpoint.findMany({
|
|
where: {
|
|
tenantId,
|
|
active: true,
|
|
events: { has: type }
|
|
}
|
|
});
|
|
|
|
// Create delivery records and queue
|
|
for (const endpoint of endpoints) {
|
|
const deliveryId = `del_${nanoid()}`;
|
|
|
|
await db.webhookDelivery.create({
|
|
data: {
|
|
id: deliveryId,
|
|
endpointId: endpoint.id,
|
|
eventId: event.id,
|
|
status: 'pending',
|
|
attempts: 0,
|
|
createdAt: new Date()
|
|
}
|
|
});
|
|
|
|
// Add to queue
|
|
await redis.lpush('webhook:deliveries', JSON.stringify({
|
|
deliveryId,
|
|
endpointId: endpoint.id,
|
|
eventId: event.id
|
|
}));
|
|
}
|
|
|
|
return event.id;
|
|
}
|
|
|
|
// Helper to publish from anywhere in the app
|
|
export function webhookEvents(tenantId: string) {
|
|
return {
|
|
projectCreated: (project: any) =>
|
|
publishWebhookEvent('project.created', tenantId, project),
|
|
projectUpdated: (project: any) =>
|
|
publishWebhookEvent('project.updated', tenantId, project),
|
|
projectDeleted: (projectId: string) =>
|
|
publishWebhookEvent('project.deleted', tenantId, { id: projectId }),
|
|
userCreated: (user: any) =>
|
|
publishWebhookEvent('user.created', tenantId, user),
|
|
subscriptionCreated: (subscription: any) =>
|
|
publishWebhookEvent('subscription.created', tenantId, subscription)
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Delivery Worker
|
|
|
|
```typescript
|
|
// lib/webhooks/worker.ts
|
|
import { db } from '@/lib/db';
|
|
import { redis } from '@/lib/redis';
|
|
import { signWebhookPayload } from './signature';
|
|
|
|
const RETRY_DELAYS = [0, 60, 300, 1800, 7200]; // seconds
|
|
|
|
interface DeliveryJob {
|
|
deliveryId: string;
|
|
endpointId: string;
|
|
eventId: string;
|
|
}
|
|
|
|
export async function processWebhookDeliveries(): Promise<void> {
|
|
while (true) {
|
|
// Get job from queue
|
|
const jobData = await redis.brpop('webhook:deliveries', 0);
|
|
if (!jobData) continue;
|
|
|
|
const job: DeliveryJob = JSON.parse(jobData[1]);
|
|
await deliverWebhook(job);
|
|
}
|
|
}
|
|
|
|
async function deliverWebhook(job: DeliveryJob): Promise<void> {
|
|
const [delivery, endpoint, event] = await Promise.all([
|
|
db.webhookDelivery.findUnique({ where: { id: job.deliveryId } }),
|
|
db.webhookEndpoint.findUnique({ where: { id: job.endpointId } }),
|
|
db.webhookEvent.findUnique({ where: { id: job.eventId } })
|
|
]);
|
|
|
|
if (!delivery || !endpoint || !event) {
|
|
console.error('Missing data for webhook delivery', job);
|
|
return;
|
|
}
|
|
|
|
// Prepare payload
|
|
const payload = JSON.stringify({
|
|
id: event.id,
|
|
type: event.type,
|
|
timestamp: event.createdAt.toISOString(),
|
|
data: event.data
|
|
});
|
|
|
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
const signature = signWebhookPayload(payload, endpoint.secret, timestamp);
|
|
|
|
// Attempt delivery
|
|
const startTime = Date.now();
|
|
let response: Response;
|
|
let error: Error | null = null;
|
|
|
|
try {
|
|
response = await fetch(endpoint.url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Webhook-Signature': signature,
|
|
'X-Webhook-Timestamp': timestamp.toString(),
|
|
'X-Webhook-Event-Type': event.type,
|
|
'X-Webhook-Event-ID': event.id,
|
|
'User-Agent': 'MyApp-Webhooks/1.0'
|
|
},
|
|
body: payload,
|
|
signal: AbortSignal.timeout(30000) // 30 second timeout
|
|
});
|
|
} catch (e) {
|
|
error = e as Error;
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
const attempts = delivery.attempts + 1;
|
|
const success = response?.ok ?? false;
|
|
|
|
// Update delivery record
|
|
await db.webhookDelivery.update({
|
|
where: { id: delivery.id },
|
|
data: {
|
|
status: success ? 'success' : (attempts >= 5 ? 'failed' : 'pending'),
|
|
attempts,
|
|
lastAttemptAt: new Date(),
|
|
nextAttemptAt: success ? null : getNextAttemptTime(attempts),
|
|
response: {
|
|
statusCode: response?.status ?? 0,
|
|
body: error?.message ?? (await response?.text().catch(() => '')),
|
|
duration
|
|
}
|
|
}
|
|
});
|
|
|
|
// Schedule retry if needed
|
|
if (!success && attempts < 5) {
|
|
const delay = RETRY_DELAYS[attempts] * 1000;
|
|
setTimeout(() => {
|
|
redis.lpush('webhook:deliveries', JSON.stringify(job));
|
|
}, delay);
|
|
}
|
|
|
|
// Log for monitoring
|
|
console.log('Webhook delivery', {
|
|
deliveryId: delivery.id,
|
|
success,
|
|
attempts,
|
|
duration,
|
|
statusCode: response?.status
|
|
});
|
|
}
|
|
|
|
function getNextAttemptTime(attempts: number): Date {
|
|
const delay = RETRY_DELAYS[Math.min(attempts, RETRY_DELAYS.length - 1)];
|
|
return new Date(Date.now() + delay * 1000);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Webhook Management API
|
|
|
|
```typescript
|
|
// app/api/webhooks/route.ts
|
|
import { NextResponse } from 'next/server';
|
|
import { getServerSession } from 'next-auth';
|
|
import { db } from '@/lib/db';
|
|
import { generateWebhookSecret } from '@/lib/webhooks/signature';
|
|
import { WEBHOOK_EVENTS } from '@/lib/webhooks/types';
|
|
import { z } from 'zod';
|
|
|
|
const createWebhookSchema = z.object({
|
|
url: z.string().url(),
|
|
events: z.array(z.enum(Object.keys(WEBHOOK_EVENTS) as [string, ...string[]])).min(1)
|
|
});
|
|
|
|
export async function GET(request: Request) {
|
|
const session = await getServerSession();
|
|
if (!session?.user?.tenantId) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const endpoints = await db.webhookEndpoint.findMany({
|
|
where: { tenantId: session.user.tenantId },
|
|
select: {
|
|
id: true,
|
|
url: true,
|
|
events: true,
|
|
active: true,
|
|
createdAt: true,
|
|
_count: {
|
|
select: {
|
|
deliveries: {
|
|
where: { status: 'failed' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return NextResponse.json({ data: endpoints });
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const session = await getServerSession();
|
|
if (!session?.user?.tenantId) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const body = await request.json();
|
|
const parsed = createWebhookSchema.safeParse(body);
|
|
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: 'Validation failed', details: parsed.error.errors },
|
|
{ status: 422 }
|
|
);
|
|
}
|
|
|
|
const secret = generateWebhookSecret();
|
|
|
|
const endpoint = await db.webhookEndpoint.create({
|
|
data: {
|
|
tenantId: session.user.tenantId,
|
|
url: parsed.data.url,
|
|
events: parsed.data.events,
|
|
secret,
|
|
active: true
|
|
}
|
|
});
|
|
|
|
// Return secret only on creation
|
|
return NextResponse.json({
|
|
data: {
|
|
id: endpoint.id,
|
|
url: endpoint.url,
|
|
events: endpoint.events,
|
|
secret // Show once!
|
|
}
|
|
}, { status: 201 });
|
|
}
|
|
|
|
// app/api/webhooks/[id]/route.ts
|
|
export async function DELETE(
|
|
request: Request,
|
|
{ params }: { params: { id: string } }
|
|
) {
|
|
const session = await getServerSession();
|
|
if (!session?.user?.tenantId) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
await db.webhookEndpoint.deleteMany({
|
|
where: {
|
|
id: params.id,
|
|
tenantId: session.user.tenantId
|
|
}
|
|
});
|
|
|
|
return new NextResponse(null, { status: 204 });
|
|
}
|
|
|
|
// app/api/webhooks/[id]/test/route.ts
|
|
export async function POST(
|
|
request: Request,
|
|
{ params }: { params: { id: string } }
|
|
) {
|
|
const session = await getServerSession();
|
|
if (!session?.user?.tenantId) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const endpoint = await db.webhookEndpoint.findFirst({
|
|
where: {
|
|
id: params.id,
|
|
tenantId: session.user.tenantId
|
|
}
|
|
});
|
|
|
|
if (!endpoint) {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
}
|
|
|
|
// Send test event
|
|
const testEvent = {
|
|
id: 'evt_test',
|
|
type: 'webhook.test',
|
|
timestamp: new Date().toISOString(),
|
|
data: { message: 'This is a test webhook' }
|
|
};
|
|
|
|
const payload = JSON.stringify(testEvent);
|
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
const signature = signWebhookPayload(payload, endpoint.secret, timestamp);
|
|
|
|
try {
|
|
const response = await fetch(endpoint.url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Webhook-Signature': signature,
|
|
'X-Webhook-Timestamp': timestamp.toString()
|
|
},
|
|
body: payload,
|
|
signal: AbortSignal.timeout(10000)
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: response.ok,
|
|
statusCode: response.status
|
|
});
|
|
} catch (error) {
|
|
return NextResponse.json({
|
|
success: false,
|
|
error: (error as Error).message
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Receiving Webhooks
|
|
|
|
```typescript
|
|
// Example: Receiving webhooks in your app
|
|
// app/api/webhooks/external/route.ts
|
|
import { NextResponse } from 'next/server';
|
|
import { verifyWebhookSignature } from '@/lib/webhooks/signature';
|
|
|
|
export async function POST(request: Request) {
|
|
const body = await request.text();
|
|
const signature = request.headers.get('X-Webhook-Signature');
|
|
const timestamp = parseInt(request.headers.get('X-Webhook-Timestamp') || '0');
|
|
|
|
if (!signature || !timestamp) {
|
|
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
|
|
}
|
|
|
|
const secret = process.env.WEBHOOK_SECRET!;
|
|
|
|
try {
|
|
const isValid = verifyWebhookSignature(body, signature, secret, timestamp);
|
|
if (!isValid) {
|
|
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
|
|
}
|
|
} catch (error) {
|
|
return NextResponse.json({ error: (error as Error).message }, { status: 401 });
|
|
}
|
|
|
|
const event = JSON.parse(body);
|
|
|
|
// Process event
|
|
switch (event.type) {
|
|
case 'project.created':
|
|
await handleProjectCreated(event.data);
|
|
break;
|
|
case 'user.created':
|
|
await handleUserCreated(event.data);
|
|
break;
|
|
default:
|
|
console.log('Unhandled event type:', event.type);
|
|
}
|
|
|
|
return NextResponse.json({ received: true });
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
| Aspect | Recommendation |
|
|
|--------|----------------|
|
|
| **Signing** | HMAC-SHA256 with timestamp |
|
|
| **Timeouts** | 30 seconds max |
|
|
| **Retries** | Exponential backoff, max 5 |
|
|
| **Idempotency** | Include event ID for dedup |
|
|
| **Logging** | Log all attempts |
|
|
| **Testing** | Provide test endpoint |
|
|
|
|
---
|
|
|
|
## Fazit
|
|
|
|
Webhooks erfordern:
|
|
|
|
1. **Security**: Signierung und Verifikation
|
|
2. **Reliability**: Retry-Logic mit Backoff
|
|
3. **Observability**: Logging und Monitoring
|
|
4. **Developer UX**: Einfache Konfiguration
|
|
|
|
Webhooks sind die Basis für Event-Driven Integrations.
|
|
|
|
---
|
|
|
|
## Bildprompts
|
|
|
|
1. "Webhook delivery flow diagram, event to endpoint"
|
|
2. "Webhook management dashboard, endpoints and delivery status"
|
|
3. "Retry strategy visualization, exponential backoff"
|
|
|
|
---
|
|
|
|
## Quellen
|
|
|
|
- [Stripe Webhooks Best Practices](https://stripe.com/docs/webhooks/best-practices)
|
|
- [GitHub Webhooks](https://docs.github.com/en/webhooks)
|
|
- [Webhook.site for Testing](https://webhook.site/)
|
|
- [Standard Webhooks Specification](https://www.standardwebhooks.com/)
|