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-abonnementen —
invoice.*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:
| Poging | Vertraging | Totaal verstreken |
|---|---|---|
| 1 | Direct | 0 |
| 2 | 1 minuut | 1 min |
| 3 | 5 minuten | 6 min |
| 4 | 30 minuten | 36 min |
| 5 | 2 uur | ~2,5 uur |
| 6 | 8 uur | ~10,5 uur |
| 7 | 24 uur | ~34,5 uur |
| 8 | 48 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.