API Documentatie
Documentatie voor de WolffPay Payment API. Integreer betalingen met onze REST API.
Introductie
Deze API is niet publiekelijk beschikbaar. Toegang wordt verleend na goedkeuring en het voltooien van de onboarding procedure. Neem contact op via info@bywolff.dev voor meer informatie.
De WolffPay API is een RESTful API voor het verwerken van betalingen via Stripe Connect. Alle bedragen zijn in centen (bijvoorbeeld €50,00 = 5000). Na het voltooien van de Stripe Connect onboarding krijg je toegang tot meerdere betaalmethodes zoals creditcard, iDEAL, SEPA Direct Debit en Bancontact. Welke methodes beschikbaar zijn hangt af van de capabilities die Stripe automatisch activeert voor je account.
https://api.bywolff.dev
/api/v1AI Developers
Deze API is bewust gedocumenteerd zodat AI-tools (zoals GitHub Copilot in VS Code) snel correcte, veilige integraties kunnen genereren. Gebruik de templates hieronder als startpunt: ze sturen Copilot naar de juiste “bron van waarheid” (OpenAPI) en leggen de kritische randvoorwaarden vast (idempotency + webhook signature verification).
https://api.bywolff.dev/api/openapi
Copilot Prompt Template (Context)
Je bent een senior TypeScript/Next.js developer.
Doel: integreer de WolffPay API in mijn webshop op een veilige, robuuste manier.
Bronnen van waarheid:
- OpenAPI spec: https://api.bywolff.dev/api/openapi
- Idempotency is verplicht voor alle POST requests (Idempotency-Key 8-255 chars)
- Webhooks komen binnen met headers: X-WolffPay-Signature, X-WolffPay-Event, X-WolffPay-Delivery-Attempt
- Webhook body is JSON: { event, data, timestamp }
Eisen:
- Schrijf type-safe code (TypeScript types voor requests/responses)
- Voeg retry + exponential backoff toe voor 429/5xx (maar nooit dubbel betalen: respecteer idempotency)
- Verifieer webhook signatures timing-safe (hex HMAC) en reject bij invalid signature
- Hou implementatie minimal en production-ready (geen pseudocode)
- Terminal betalingen: gebruik POST /api/v1/payments/terminal met action veld (create_intent, collect_and_process, etc.)
Lever op:
- Een kleine client wrapper (fetch-based) voor payments + refunds + terminal
- Een Next.js route handler voorbeeld voor webhooks (signature verification)
Copilot Taak: Payments client
Taak: maak een functie createPayment() die:
- POST /api/v1/payments aanroept
- headers zet: Authorization, Content-Type, Idempotency-Key
- een idempotency key maakt per order attempt (deterministisch per orderId+attempt)
- errors netjes mapped naar een bruikbare error (met status + message)
- return type: { id, clientSecret, status, ... } (type-safe)
Copilot Taak: Webhook handler
Taak: maak een webhook handler die:
- raw body leest
- X-WolffPay-Signature verifieert (t=..., v1=<hex>) met HMAC-SHA256 over "{timestamp}.{rawBody}"
- tolerance 300s gebruikt
- switch op event.event: payment.succeeded | payment.failed | payment.refunded
- altijd snel 2xx returned en verwerking async kan doen
Authenticatie
Authenticatie gebeurt via Bearer tokens. Je API key ontvang je na het aanmaken van een account, het voltooien van Stripe Connect onboarding, en goedkeuring door WolffPay.
curl -X GET "https://api.bywolff.dev/api/v1" \ -H "Authorization: Bearer wp_live_..."
API Key Format
| Onderdeel | Beschrijving |
|---|---|
wp_ | WolffPay prefix |
live_ / test_ | Environment indicator |
[hex] | Unieke identifier |
Stripe Connect Setup
WolffPay gebruikt Stripe Connect om betalingen voor jouw webshop te verwerken. Elk account heeft zijn eigen Stripe Connect merchant account met eigen capabilities.
Setup Flow
- Account wordt aangemaakt in WolffPay dashboard
- Kies Test Account of Live Account (permanent)
- Voltooi Stripe Connect onboarding via de onboarding link
- Stripe activeert automatisch payment capabilities op basis van je bedrijfsgegevens
- API key wordt gegenereerd na goedkeuring
Payment Capabilities
Na onboarding activeert Stripe automatisch betaalmethodes die jouw account kan accepteren. Deze capabilities zijn zichtbaar in het WolffPay dashboard onder Geaccepteerde Betaalmethodes.
| Capability | Beschrijving |
|---|---|
card_payments | Creditcards en debitcards (Visa, Mastercard, etc.) |
ideal_payments | iDEAL (Nederlandse banken) |
sepa_debit_payments | SEPA Direct Debit (automatische incasso) |
bancontact_payments | Bancontact (Belgische banken) |
sofort_payments | Sofort / Klarna (afhankelijk van land/Stripe instellingen) |
card_present | Fysieke terminal (WisePad 3 / Stripe Terminal) |
Stripe.js Frontend Integratie
WolffPay gebruikt Stripe Connect Express. Voor correcte integratie in je webshop moet je de platform publishable key gebruiken MET de stripeAccount parameter.
stripeAccount parameter is VERPLICHT. Zonder deze parameter krijg je een Invalid API Key error.Stap 1: Haal configuratie op
// Haal WolffPay configuratie op
const response = await fetch('https://api.bywolff.dev/api/config', {
headers: {
'Authorization': 'Bearer wp_test_...'
}
});
const config = await response.json();
// Response bevat:
// {
// "success": true,
// "data": {
// "stripePublishableKey": "pk_test_...", // Platform key
// "stripeAccountId": "acct_...", // Connected account ID
// "chargesEnabled": true,
// "supportedPaymentMethods": ["card", "ideal", "sepa_debit"]
// }
// }Stap 2: Initialiseer Stripe.js CORRECT
import { loadStripe } from '@stripe/stripe-js';
// ❌ FOUT - Dit geeft "Invalid API Key" error
const stripe = loadStripe(config.data.stripePublishableKey);
// ✅ CORRECT - Met stripeAccount parameter
const stripe = loadStripe(config.data.stripePublishableKey, {
stripeAccount: config.data.stripeAccountId // VERPLICHT!
});Stap 3: Gebruik in React met Elements
import { Elements, PaymentElement } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
function CheckoutPage() {
const [stripePromise, setStripePromise] = useState(null);
const [clientSecret, setClientSecret] = useState('');
useEffect(() => {
// Haal config op
fetch('https://api.bywolff.dev/api/config', {
headers: { 'Authorization': 'Bearer wp_test_...' }
})
.then(res => res.json())
.then(config => {
// Initialiseer Stripe MET stripeAccount
const stripe = loadStripe(config.data.stripePublishableKey, {
stripeAccount: config.data.stripeAccountId
});
setStripePromise(stripe);
});
// Maak payment intent
fetch('https://api.bywolff.dev/api/v1/payments', {
method: 'POST',
headers: {
'Authorization': 'Bearer wp_test_...',
'Content-Type': 'application/json',
'Idempotency-Key': 'order-123'
},
body: JSON.stringify({
amount: 5000,
currency: 'eur',
description: 'Order #123'
})
})
.then(res => res.json())
.then(data => setClientSecret(data.data.clientSecret));
}, []);
if (!stripePromise || !clientSecret) {
return <div>Loading...</div>;
}
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<PaymentElement />
<button type="submit">Betalen</button>
</Elements>
);
}WolffPay gebruikt Stripe Connect Express. De publishable key is van het WolffPay platform, maar betalingen moeten naar jouw connected account gaan. De
stripeAccount parameter zorgt ervoor dat Stripe weet naar welk account de betaling moet.Rate Limiting
De API heeft een limiet van 100 requests per minuut per API key. Rate limit informatie wordt meegestuurd in de response headers.
| Header | Beschrijving |
|---|---|
X-RateLimit-Limit | Maximum aantal requests per window (100) |
X-RateLimit-Remaining | Resterende requests in huidige window |
X-RateLimit-Reset | Unix timestamp wanneer de limiet reset |
429 Too Many Requests. Implementeer exponential backoff in je integratie.Idempotency
Om dubbele betalingen te voorkomen bij netwerkstoringen is de Idempotency-Key header verplicht voor alle POST requests. Gebruik een unieke key per request.
POST /api/v1/payments Authorization: Bearer wp_live_... Content-Type: application/json Idempotency-Key: order-12345-payment-attempt-1
| Vereiste | Waarde |
|---|---|
| Lengte | 8-255 karakters |
| Geldigheid | 24 uur |
| Scope | Per API key + endpoint |
Idempotent-Replayed: true.Payments
Endpoints voor het aanmaken en beheren van betalingen.
Maak een nieuwe Payment Intent aan. Retourneert een clientSecret voor gebruik met Stripe Elements op de frontend.
Request Body
| Parameter | Type | Vereist | Beschrijving |
|---|---|---|---|
amount | number | Ja | Bedrag in centen (min: 50, max: 99999999) |
currency | string | Nee | Valuta: eur, usd, gbp. Default: eur |
description | string | Ja | Beschrijving (1-1000 karakters) |
metadata | object | Nee | Extra data (max 8KB) |
paymentMethodTypes | string[] | Nee | Betaalmethodes voor checkout. Alleen methodes die actief zijn in je Stripe capabilities werken. Default: ["card", "ideal", "sepa_debit"] |
captureMethod | string | Nee | automatic of manual. Default: automatic |
Ondersteunde Betaalmethodes
Je kunt alleen betaalmethodes gebruiken die actief zijn in je Stripe Connect capabilities. Check het dashboard voor een lijst van jouw actieve capabilities.
{
"amount": 5000,
"currency": "eur",
"description": "Order #12345",
"metadata": {
"orderId": "12345",
"customerEmail": "klant@example.com"
},
"paymentMethodTypes": ["card", "ideal"]
}{
"success": true,
"data": {
"id": "pi_3abc123",
"clientSecret": "pi_3abc123_secret_xyz",
"amount": 5000,
"currency": "eur",
"status": "requires_payment_method",
"description": "Order #12345",
"paymentMethodTypes": ["card", "ideal"],
"captureMethod": "automatic",
"created": "2025-12-16T10:30:00.000Z"
}
}Haal een lijst op van alle betalingen voor je account.
Query Parameters
| Parameter | Type | Default | Beschrijving |
|---|---|---|---|
limit | number | 10 | Aantal resultaten (max: 100) |
starting_after | string | - | Cursor voor pagination (Firestore document ID uit vorige response: pagination.nextCursor) |
test_mode | boolean | - | Optioneel filter: true of false |
{
"success": true,
"data": [
{
"id": "doc123",
"stripePaymentIntentId": "pi_abc123",
"amount": 5000,
"currency": "eur",
"description": "Order #12345",
"status": "succeeded",
"createdAt": "2025-12-16T10:30:00.000Z"
}
],
"pagination": {
"limit": 10,
"count": 1,
"hasMore": false
}
}Haal details op van een specifieke betaling. De :id kan een document ID of Stripe Payment Intent ID (pi_...) zijn.
{
"success": true,
"data": {
"id": "doc123",
"paymentIntentId": "pi_abc123",
"amount": 5000,
"currency": "eur",
"status": "succeeded",
"description": "Order #12345",
"createdAt": "2025-12-16T10:30:00.000Z"
}
}Voer een actie uit op een bestaande Payment Intent. Gebruik dit vooral voor betalingen met captureMethod op manual.
Request Body
| Parameter | Type | Vereist | Beschrijving |
|---|---|---|---|
action | string | Ja | capture of cancel |
{
"action": "capture"
}{
"success": true,
"data": {
"id": "doc123",
"paymentIntentId": "pi_abc123",
"status": "succeeded",
"action": "capture"
}
}Terminal Betalingen
Verwerk fysieke pinbetalingen via een Stripe Terminal reader (bijv. WisePad 3). De fee-structuur is identiek aan online betalingen: vast bedrag + percentage naar WolffPay, rest naar jou.
Maak een kortlevende Stripe Terminal ConnectionToken aan voor je client app. Dit endpoint is geauthenticeerd met je WolffPay API key.
{
"locationId": "tml_abc123"
}{
"success": true,
"data": {
"secret": "pst_test_terminal_connection_token",
"locationId": "tml_abc123"
}
}Alle terminal-acties gaan via dit endpoint. Het action veld bepaalt welke actie wordt uitgevoerd.
Beschikbare Acties
| Action | Beschrijving |
|---|---|
create_intent | Maak een card_present Payment Intent aan (met Connect fee split) |
collect_payment_method | Laat de reader wachten op kaartpresentatie (smart readers) |
process_payment | Verwerk de betaling op de reader (smart readers) |
collect_and_process | Combinatie: collect + process in één stap (smart readers) |
cancel_reader_action | Annuleer de huidige actie op de reader (smart readers) |
cancel_payment | Annuleer de Payment Intent |
capture_payment | Capture een handmatig geautoriseerde Payment Intent (captureMethod: manual) |
Stap 1: create_intent
| Parameter | Type | Vereist | Beschrijving |
|---|---|---|---|
action | string | Ja | create_intent |
amount | number | Ja | Bedrag in centen (min: 50) |
currency | string | Nee | Alleen eur (default) |
description | string | Ja | Beschrijving (1-1000 karakters) |
metadata | object | Nee | Extra data (max 8KB) |
captureMethod | string | Nee | automatic (default) of manual |
{
"action": "create_intent",
"amount": 5000,
"currency": "eur",
"description": "Bestelling #456",
"metadata": {
"order_id": "456"
}
}{
"success": true,
"data": {
"id": "pi_3abc456",
"clientSecret": "pi_3abc456_secret_xyz",
"amount": 5000,
"currency": "eur",
"status": "requires_payment_method",
"captureMethod": "automatic",
"paymentMethodTypes": ["card_present"],
"fee": {
"applicationFeeAmount": 150,
"fixedFee": 25,
"percentageFee": 2.5,
"calculatedPercentageFee": 125
},
"connect": {
"destination": "acct_xxx",
"onBehalfOf": "acct_xxx"
}
}
}Stap 2: collect_and_process (aanbevolen)
Na het aanmaken van een Payment Intent, stuur je de reader opdracht om de kaart te lezen en de betaling in één keer te verwerken. Gebruik collect_and_process voor de simpelste flow met smart readers.
| Parameter | Type | Vereist | Beschrijving |
|---|---|---|---|
action | string | Ja | collect_and_process |
readerId | string | Ja | Stripe Terminal Reader ID (tmr_...) |
paymentIntentId | string | Ja | Payment Intent ID uit stap 1 (pi_...) |
{
"action": "collect_and_process",
"readerId": "tmr_abc123",
"paymentIntentId": "pi_3abc456"
}{
"success": true,
"data": {
"paymentIntentId": "pi_3abc456",
"status": "succeeded",
"amountReceived": 5000
}
}Annuleren
Gebruik cancel_reader_action om een lopende reader actie te stoppen of cancel_payment om de Payment Intent te annuleren.
// Annuleer reader actie
{ "action": "cancel_reader_action", "readerId": "tmr_abc123" }
// Annuleer Payment Intent
{ "action": "cancel_payment", "paymentIntentId": "pi_3abc456" }Manual capture
Als je captureMethod: manual gebruikt bij create_intent, rond je de betaling af met capture_payment.
{ "action": "capture_payment", "paymentIntentId": "pi_3abc456" }- Roep
/api/v1/terminal/connection-tokenaan vanuit je app backend - Roep
create_intentaan met bedrag + beschrijving - Roep
collect_and_processaan met reader ID + payment intent ID - Klant houdt pas/telefoon tegen de reader, en bij manual capture roep je daarna
capture_paymentaan
WisePad 3 (WPC32) gebruikt de Stripe Terminal SDK Bluetooth flow. Reader discover/connect loopt client-side via SDK.
Refunds
Endpoints voor het aanmaken en ophalen van terugbetalingen.
Maak een refund aan voor een bestaande betaling. Zonder amount wordt het volledige bedrag terugbetaald.
Request Body
| Parameter | Type | Vereist | Beschrijving |
|---|---|---|---|
paymentIntentId | string | Ja | Stripe Payment Intent ID (pi_...) |
amount | number | Nee | Partial refund bedrag in centen |
reason | string | Nee | duplicate, fraudulent, of requested_by_customer |
metadata | object | Nee | Extra data (wordt meegestuurd naar Stripe; max 8KB aanbevolen) |
{
"paymentIntentId": "pi_abc123",
"amount": 2500,
"reason": "requested_by_customer"
}{
"success": true,
"data": {
"id": "re_abc123",
"paymentIntentId": "pi_abc123",
"amount": 2500,
"currency": "eur",
"status": "succeeded",
"reason": "requested_by_customer",
"created": "2025-12-16T11:00:00.000Z"
}
}Haal een lijst op van refunds. Optioneel te filteren op payment.
Query Parameters
| Parameter | Type | Beschrijving |
|---|---|---|
limit | number | Aantal resultaten (default: 10, max: 100) |
paymentIntentId | string | Filter op specifieke betaling |
starting_after | string | Cursor voor pagination (Firestore document ID uit vorige response: pagination.nextCursor) |
{
"success": true,
"data": [
{
"id": "doc_ref_123",
"stripeRefundId": "re_abc123",
"stripePaymentIntentId": "pi_abc123",
"amount": 2500,
"currency": "eur",
"status": "succeeded",
"reason": "requested_by_customer",
"createdAt": "2025-12-16T11:00:00.000Z"
}
],
"pagination": {
"limit": 10,
"count": 1,
"hasMore": false
}
}Error Codes
Alle errors worden geretourneerd in een consistent format.
{
"success": false,
"error": "Description of what went wrong"
}HTTP Status Codes
| Status | Betekenis |
|---|---|
400 | Bad Request - Ongeldige parameters of validatiefout |
401 | Unauthorized - Ongeldige of missende API key |
403 | Forbidden - Account inactief of terminal uitgeschakeld |
404 | Not Found - Resource niet gevonden |
429 | Too Many Requests - Rate limit overschreden |
500 | Internal Server Error - Server probleem |
Veelvoorkomende Errors
| Error | Oorzaak |
|---|---|
| Invalid amount. Minimum is 50 cents. | Bedrag te laag (min €0.50) |
| Idempotency-Key header is required | POST request zonder idempotency key |
| Rate limit exceeded | Meer dan 100 requests/minuut |
| Stripe Connect not configured | Onboarding niet voltooid |
| Terminal payments are disabled for this customer. | Terminal niet ingeschakeld (contacteer WolffPay) |
| This terminal reader is disabled in WolffPay dashboard. | Reader uitgeschakeld door WolffPay admin |
Webhooks
WolffPay stuurt webhook notificaties naar je server wanneer events plaatsvinden (bijv. betaling succesvol). Configureer je webhook URL in het dashboard bij de klant. Elke webhook is cryptografisch gesigned voor security.
Webhook URL Configuratie
Stel je webhook URL in via het dashboard: Dashboard → Customers → [Klant] → Webhook Configuratie. Je webhook endpoint moet een POST request accepteren en idealiter binnen enkele seconden een 2xx status code teruggeven.
Webhook Events
| Event Type | Beschrijving |
|---|---|
payment.succeeded | Betaling succesvol afgerond |
payment.failed | Betaling mislukt |
payment.refunded | Terugbetaling verwerkt (volledig of gedeeltelijk) |
Webhook Payload
{
"event": "payment.succeeded",
"data": {
"id": "pi_abc123",
"amount": 5000,
"currency": "eur",
"description": "Order #12345",
"status": "succeeded",
"metadata": {
"order_id": "12345",
"customer_email": "klant@example.com"
}
},
"timestamp": "2026-02-18T12:34:56.789Z"
}Signature Verificatie (Verplicht!)
Elke webhook bevat een X-WolffPay-Signature header met HMAC-SHA256 signature. Verifieer ALTIJD de signature om te garanderen dat de webhook van WolffPay komt.
POST /api/webhooks/wolffpay
Content-Type: application/json
X-WolffPay-Signature: t=1734355200,v1=a1b2c3d4e5f6...
X-WolffPay-Event: payment.succeeded
X-WolffPay-Delivery-Attempt: 1
{
"event": "payment.succeeded",
...
}Verificatie Algoritme
De signature header heeft het formaat: t=timestamp,v1=signature
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.WOLFFPAY_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300; // 5 minuten
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string
): boolean {
// 1. Parse signature header
const parts = signatureHeader.split(',');
let timestamp = '';
let expectedSignature = '';
for (const part of parts) {
const [key, value] = part.split('=');
if (key === 't') timestamp = value;
if (key === 'v1') expectedSignature = value;
}
if (!timestamp || !expectedSignature) {
return false;
}
// 2. Check timestamp (bescherming tegen replay attacks)
const now = Math.floor(Date.now() / 1000);
const timestampAge = Math.abs(now - parseInt(timestamp));
if (timestampAge > TOLERANCE_SECONDS) {
console.error('Webhook timestamp te oud:', timestampAge, 'seconden');
return false;
}
// 3. Construeer signed payload
const signedPayload = `${timestamp}.${rawBody}`;
// 4. Bereken HMAC-SHA256 signature
const computedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// 5. Vergelijk signatures (timing-safe)
try {
return crypto.timingSafeEqual(
Buffer.from(computedSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
} catch {
return false;
}
}Next.js Webhook Handler Voorbeeld
// app/api/webhooks/wolffpay/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.WOLFFPAY_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;
function verifySignature(body: string, signature: string): boolean {
const parts = signature.split(',');
const timestamp = parts.find(p => p.startsWith('t='))?.split('=')[1];
const expectedSig = parts.find(p => p.startsWith('v1='))?.split('=')[1];
if (!timestamp || !expectedSig) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const signedPayload = `${timestamp}.${body}`;
const computed = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(computed, 'hex'),
Buffer.from(expectedSig, 'hex')
);
}
export async function POST(request: NextRequest) {
try {
// 1. Lees raw body (belangrijk!)
const body = await request.text();
const signature = request.headers.get('X-WolffPay-Signature');
// 2. Verifieer signature
if (!signature || !verifySignature(body, signature)) {
console.error('Invalid webhook signature');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// 3. Parse event
const event = JSON.parse(body);
console.log('Webhook verified:', event.event);
// 4. Handle event types
switch (event.event) {
case 'payment.succeeded':
await handlePaymentSuccess(event.data);
break;
case 'payment.failed':
await handlePaymentFailed(event.data);
break;
case 'payment.refunded':
await handleRefund(event.data);
break;
default:
console.log('Unhandled event type:', event.event);
}
// 5. Return 200 snel (binnen 5 seconden)
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
);
}
}
async function handlePaymentSuccess(paymentIntent: any) {
const orderId = paymentIntent.metadata?.order_id;
console.log('Payment succeeded for order', orderId);
// TODO: Update order status in database
// TODO: Send confirmation email
// TODO: Fulfill order
}
async function handlePaymentFailed(paymentIntent: any) {
const orderId = paymentIntent.metadata?.order_id;
console.log('Payment failed for order', orderId);
// TODO: Update order status
// TODO: Notify customer
}
async function handleRefund(refund: any) {
console.log('Refund processed');
// TODO: Update order status
// TODO: Notify customer
}Testing Webhooks
Voor local development gebruik je ngrok of een vergelijkbare tunnel service:
# Start ngrok tunnel ngrok http 3000 # Output: https://abc123.ngrok.io # Update webhook URL in WolffPay dashboard: # https://abc123.ngrok.io/api/webhooks/wolffpay
- Verifieer ALTIJD de webhook signature
- Return 200 status binnen 5 seconden
- Process webhooks async indien mogelijk
- Log failures voor debugging
- Implementeer idempotency (zelfde event kan meerdere keren komen)