Initial commit: Portfolio Website
Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
# 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/)
|
||||
Reference in New Issue
Block a user