Terug naar blog
webhooksintegrationsapiarchitecturesaas

Een betrouwbaar webhook-systeem bouwen voor je SaaS: de complete gids

Door SaaS Masters15 maart 20269 min leestijd
Een betrouwbaar webhook-systeem bouwen voor je SaaS: de complete gids

Een betrouwbaar webhook-systeem bouwen voor je SaaS: de complete gids

Je SaaS bestaat niet in een vacuüm. Op het moment dat klanten je product gaan integreren in hun workflows, hebben ze één ding boven alles nodig: real-time event-notificaties. Dat is waar webhooks om de hoek komen kijken.

Een webhook-systeem klinkt simpel — stuur een HTTP POST als er iets gebeurt. Maar een systeem bouwen dat betrouwbaar, veilig en schaalbaar is, is een technische uitdaging waar veel SaaS-teams over struikelen. Verloren events, dubbele deliveries, beveiligingslekken en debug-nachtmerries zijn veelvoorkomende valkuilen.

Deze gids leidt je door het bouwen van een productie-waardig webhook-systeem, van begin tot eind.

Waarom webhooks belangrijk zijn voor SaaS

Webhooks zijn de standaard voor event-driven integraties. In tegenstelling tot polling (waarbij clients steeds opnieuw vragen "iets nieuws?"), pushen webhooks events naar subscribers op het moment dat ze plaatsvinden. Voordelen zijn:

  • Real-time updates — klanten worden direct op de hoogte gebracht
  • Minder API-belasting — geen polling meer elke 30 seconden
  • Betere integraties — Zapier, Make en n8n vertrouwen allemaal op webhooks
  • Klantretentie — diepe integraties maken je product moeilijker te vervangen

Als je een B2B SaaS bouwt, is een solide webhook-systeem geen luxe — het is een verwachting.

Architectuuroverzicht

Een betrouwbaar webhook-systeem heeft vijf kerncomponenten:

┌─────────────┐     ┌──────────────┐     ┌───────────────┐
│  Je App     │────▶│  Event Queue │────▶│  Delivery     │
│  (events)   │     │  (Redis/SQS) │     │  Worker       │
└─────────────┘     └──────────────┘     └───────┬───────┘
                                                  │
                                          ┌───────▼───────┐
                                          │  Klant-        │
                                          │  Endpoint      │
                                          └───────┬───────┘
                                                  │
                                          ┌───────▼───────┐
                                          │  Retry Queue   │
                                          │  + Dead Letter │
                                          └───────────────┘

Stuur webhooks nooit synchroon vanuit je hoofdapplicatieflow. Zet het event altijd in een queue en laat een achtergrondworker de delivery afhandelen. Dit voorkomt dat trage of falende klant-endpoints de prestaties van je applicatie beïnvloeden.

Stap 1: Ontwerp je event-schema

Consistentie is key. Elk webhook-event moet dezelfde structuur volgen:

{
  "id": "evt_2xK9mPqR4sT7vW1y",
  "type": "invoice.paid",
  "apiVersion": "2026-03-01",
  "createdAt": "2026-03-15T08:00:00Z",
  "data": {
    "id": "inv_8nM3kL6jH2fD",
    "amount": 9900,
    "currency": "eur",
    "customerId": "cus_4rT7yU2iO9pA"
  }
}

Belangrijke ontwerpbeslissingen:

  • Uniek event-ID — essentieel voor idempotentie aan de ontvangende kant
  • Genaamruimte event-types — gebruik resource.action-formaat (bijv. subscription.created, invoice.paid)
  • API-versioning — laat je payloads evolueren zonder bestaande integraties te breken
  • Timestamp — neem altijd op wanneer het event plaatsvond
  • Volledig object in data — stuur het complete object mee, niet alleen een ID (bespaart klanten een extra API-call)

Stap 2: Abonnementsbeheer

Laat klanten webhook-endpoints registreren via je API en dashboard:

// Webhook endpoint registratie
interface WebhookEndpoint {
  id: string;
  url: string;            // HTTPS-endpoint van de klant
  events: string[];       // ["invoice.paid", "subscription.*"]
  secret: string;         // Voor handtekeningverificatie
  active: boolean;
  createdAt: Date;
  metadata?: Record<string, string>;
}

// Database-schema (Prisma)
model WebhookEndpoint {
  id        String   @id @default(cuid())
  tenantId  String
  url       String
  events    String[] // Geabonneerde event-types
  secret    String   // HMAC-ondertekeningsgeheim
  active    Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  tenant    Tenant   @relation(fields: [tenantId], references: [id])
  deliveries WebhookDelivery[]
}

Belangrijke overwegingen:

  • Sta alleen HTTPS-URL's toe — stuur nooit webhook-payloads over onversleutelde verbindingen
  • Ondersteun wildcard-abonnementeninvoice.* abonneert op alle invoice-events
  • Genereer een uniek geheim per endpoint — gebruikt voor HMAC-handtekeningverificatie
  • Valideer URL's bij registratie — stuur een test-event of verificatie-challenge

Stap 3: Veilig ondertekenen met HMAC

Elke webhook-delivery moet ondertekend zijn zodat de ontvanger kan verifiëren dat het van jou komt:

import crypto from 'crypto';

function signWebhookPayload(
  payload: string, 
  secret: string, 
  timestamp: number
): string {
  const signedContent = `${timestamp}.${payload}`;
  return crypto
    .createHmac('sha256', secret)
    .update(signedContent)
    .digest('hex');
}

function buildWebhookHeaders(
  payload: string, 
  secret: string
): Record<string, string> {
  const timestamp = Math.floor(Date.now() / 1000);
  const signature = signWebhookPayload(payload, secret, timestamp);
  
  return {
    'Content-Type': 'application/json',
    'X-Webhook-Id': crypto.randomUUID(),
    'X-Webhook-Timestamp': timestamp.toString(),
    'X-Webhook-Signature': `v1=${signature}`,
  };
}

Het opnemen van de timestamp in de handtekening voorkomt replay-aanvallen — ontvangers moeten handtekeningen ouder dan 5 minuten weigeren.

Stap 4: De delivery-worker

Dit is het hart van je systeem. De worker haalt events uit de queue en levert ze af:

import { Queue, Worker } from 'bullmq';

const webhookQueue = new Queue('webhooks', { 
  connection: redis 
});

// Event in de queue plaatsen
async function emitWebhookEvent(
  tenantId: string, 
  eventType: string, 
  data: any
) {
  const event = {
    id: `evt_${generateId()}`,
    type: eventType,
    apiVersion: '2026-03-01',
    createdAt: new Date().toISOString(),
    data,
  };

  // Vind alle matchende endpoints voor deze tenant
  const endpoints = await db.webhookEndpoint.findMany({
    where: {
      tenantId,
      active: true,
      events: { hasSome: [eventType, eventType.split('.')[0] + '.*'] },
    },
  });

  // Queue een delivery-job voor elk endpoint
  for (const endpoint of endpoints) {
    await webhookQueue.add('deliver', {
      event,
      endpointId: endpoint.id,
      url: endpoint.url,
      secret: endpoint.secret,
    }, {
      attempts: 8,
      backoff: { type: 'exponential', delay: 60_000 },
      removeOnComplete: 1000,
      removeOnFail: 5000,
    });
  }
}

// De delivery-worker
const worker = new Worker('webhooks', async (job) => {
  const { event, endpointId, url, secret } = job.data;
  const payload = JSON.stringify(event);
  const headers = buildWebhookHeaders(payload, secret);

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers,
      body: payload,
      signal: controller.signal,
    });

    // Log de delivery-poging
    await db.webhookDelivery.create({
      data: {
        endpointId,
        eventId: event.id,
        eventType: event.type,
        statusCode: response.status,
        success: response.status >= 200 && response.status < 300,
        attemptNumber: job.attemptsMade + 1,
      },
    });

    // Behandel non-2xx als fout
    if (response.status < 200 || response.status >= 300) {
      throw new Error(`Endpoint gaf ${response.status} terug`);
    }
  } finally {
    clearTimeout(timeout);
  }
}, { connection: redis, concurrency: 20 });

Stap 5: Retry-strategie met exponentiële backoff

Mislukte deliveries moeten opnieuw geprobeerd worden met toenemende vertragingen. Een veelgebruikt patroon:

PogingVertragingTotaal verstreken
1Direct0
21 minuut1 min
35 minuten6 min
430 minuten36 min
52 uur~2,5 uur
68 uur~10,5 uur
724 uur~34,5 uur
848 uur~82,5 uur

Nadat alle retries zijn uitgeput, verplaats het event naar een dead letter queue en informeer optioneel de klant:

worker.on('failed', async (job, err) => {
  if (job.attemptsMade >= job.opts.attempts) {
    // Alle retries uitgeput — endpoint deactiveren
    await db.webhookEndpoint.update({
      where: { id: job.data.endpointId },
      data: { active: false },
    });
    
    // Klant informeren via e-mail
    await sendEmail({
      to: customer.email,
      subject: 'Webhook-endpoint gedeactiveerd',
      body: `Je endpoint ${job.data.url} is gedeactiveerd na herhaalde fouten.`,
    });
  }
});

Stap 6: Delivery-logs en debugging

Geef je klanten een webhook delivery-log in je dashboard. Dit is cruciaal voor debugging:

model WebhookDelivery {
  id            String   @id @default(cuid())
  endpointId    String
  eventId       String
  eventType     String
  statusCode    Int?
  success       Boolean
  attemptNumber Int
  requestBody   String?  // Opslaan voor debugging
  responseBody  String?  // Eerste 1KB van response
  duration      Int?     // ms
  createdAt     DateTime @default(now())

  endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id])
}

Essentiële dashboard-functies:

  • Recente deliveries met status, responscode en timing
  • Retry-knop voor mislukte deliveries
  • Test endpoint-knop die een voorbeeldevent stuurt
  • Event-type filter om specifieke events snel te vinden

Stap 7: Rate limiting en circuit breaking

Bescherm zowel jezelf als de endpoints van je klanten:

import { RateLimiterRedis } from 'rate-limiter-flexible';

// Per-endpoint rate limiter: max 100 deliveries per minuut
const endpointLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: 'webhook_rl',
  points: 100,
  duration: 60,
});

// Circuit breaker: pauzeer delivery na 5 opeenvolgende fouten
async function checkCircuitBreaker(endpointId: string): Promise<boolean> {
  const recentFailures = await db.webhookDelivery.count({
    where: {
      endpointId,
      success: false,
      createdAt: { gte: new Date(Date.now() - 300_000) }, // Laatste 5 min
    },
  });
  return recentFailures < 5; // true = circuit gesloten (OK om te sturen)
}

Stap 8: Monitoring en alerting

Houd deze metrics bij voor je webhook-systeem:

  • Delivery-succespercentage — moet >99% zijn voor gezonde endpoints
  • Queue-diepte — groeiende queue betekent dat workers niet bijkunnen
  • P95 delivery-latentie — hoe lang van event tot delivery
  • Retry-percentage — veel retries duiden op problematische endpoints
  • Dead letter queue-grootte — mislukte events die aandacht nodig hebben
// Prometheus metrics voorbeeld
import { Counter, Histogram, Gauge } from 'prom-client';

const webhookDeliveries = new Counter({
  name: 'webhook_deliveries_total',
  help: 'Totaal aantal webhook delivery-pogingen',
  labelNames: ['status', 'event_type'],
});

const deliveryDuration = new Histogram({
  name: 'webhook_delivery_duration_seconds',
  help: 'Webhook delivery-duur',
  buckets: [0.1, 0.5, 1, 2, 5, 10],
});

const queueDepth = new Gauge({
  name: 'webhook_queue_depth',
  help: 'Aantal wachtende webhook deliveries',
});

Productie-checklist

Controleer voordat je je webhook-systeem lanceert:

  • Alleen HTTPS — weiger HTTP-URL's bij registratie
  • HMAC-handtekeningen — elke delivery is ondertekend
  • Timestamp in handtekening — voorkomt replay-aanvallen
  • 10 seconden timeout — wacht niet eeuwig op trage endpoints
  • Exponentiële backoff — minimaal 5 retry-pogingen
  • Dead letter queue — laat events niet stilletjes verdwijnen
  • Idempotentie-keys — unieke event-ID's voor deduplicatie
  • Delivery-logs — klanten kunnen zien wat er verstuurd is
  • Rate limiting — bescherm endpoints tegen event-stormen
  • IP-allowlist documentatie — publiceer je uitgaande IP's
  • Testmodus — klanten kunnen test-events sturen vanuit het dashboard
  • Event-catalogus — documenteer elk event-type en zijn payload

Conclusie

Een webhook-systeem is een van die features die simpel lijkt totdat je het echt gaat bouwen. Het verschil tussen een speelgoedimplementatie en een productie-waardig systeem zit in betrouwbaarheid — retries, monitoring, beveiliging en debugging-tools.

Investeer de tijd om het goed te bouwen. De integraties van je klanten hangen ervan af, en elke gemiste webhook is ergens een gebroken workflow. Het goede nieuws: als je eenmaal een solide fundament hebt, schaalt het prachtig en wordt het een van de sterkste verkooppunten van je product.

Hulp nodig bij het bouwen van een betrouwbaar webhook-systeem voor je SaaS? Neem contact op — wij hebben event-driven systemen gebouwd voor tientallen SaaS-producten.