Menu
Back to Blog
1 min read
Backend

Webhook Implementation

Webhooks implementieren für Event-Driven Architecture. Signierung, Retry-Logic und Webhook Management für SaaS.

WebhooksEvent-DrivenSignature VerificationRetry LogicWebhook DeliveryIntegration
Webhook Implementation

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

// 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

// 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

// 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

// 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

// 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

// 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

AspectRecommendation
**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