API Documentatie

Documentatie voor de WolffPay Payment API. Integreer betalingen met onze REST API.

Introductie

Private API - Alleen toegankelijk op aanvraag
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.

Base URL
https://api.bywolff.dev
API prefix: /api/v1
Tip voor AI-developers: Klik op Kopieer AI Prompt bovenaan om een complete prompt te kopiëren die je kunt gebruiken in VS Code Copilot of andere AI-tools.

AI 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).

Bron van waarheid
OpenAPI spec (YAML):
OpenAPI URL
https://api.bywolff.dev/api/openapi
Tip: laat Copilot eerst de OpenAPI spec lezen en pas daarna code genereren.

Copilot Prompt Template (Context)

Plak in Copilot Chat
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 prompt
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 prompt
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
AI-valkuilen: laat AI nooit “gokken” over webhook headers of event types. Gebruik altijd de OpenAPI spec + deze docs; anders krijg je subtiele bugs (bijv. signature compare zonder hex encoding).

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.

Request
curl -X GET "https://api.bywolff.dev/api/v1" \
  -H "Authorization: Bearer wp_live_..."

API Key Format

OnderdeelBeschrijving
wp_WolffPay prefix
live_ / test_Environment indicator
[hex]Unieke identifier
Bewaar je API key veilig en deel deze nooit publiekelijk. API keys geven volledige toegang tot betalingen in je account.

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

  1. Account wordt aangemaakt in WolffPay dashboard
  2. Kies Test Account of Live Account (permanent)
  3. Voltooi Stripe Connect onboarding via de onboarding link
  4. Stripe activeert automatisch payment capabilities op basis van je bedrijfsgegevens
  5. 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.

CapabilityBeschrijving
card_paymentsCreditcards en debitcards (Visa, Mastercard, etc.)
ideal_paymentsiDEAL (Nederlandse banken)
sepa_debit_paymentsSEPA Direct Debit (automatische incasso)
bancontact_paymentsBancontact (Belgische banken)
sofort_paymentsSofort / Klarna (afhankelijk van land/Stripe instellingen)
card_presentFysieke terminal (WisePad 3 / Stripe Terminal)
Test vs Live Accounts: Test accounts kunnen alleen test-betalingen verwerken. Live accounts verwerken echte betalingen. Deze keuze is permanent en kan later niet gewijzigd worden.

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.

Kritisch: De stripeAccount parameter is VERPLICHT. Zonder deze parameter krijg je een Invalid API Key error.

Stap 1: Haal configuratie op

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

Stripe.js initialisatie
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

Complete React component
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>
  );
}
Waarom stripeAccount parameter?
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.

HeaderBeschrijving
X-RateLimit-LimitMaximum aantal requests per window (100)
X-RateLimit-RemainingResterende requests in huidige window
X-RateLimit-ResetUnix timestamp wanneer de limiet reset
Bij overschrijding ontvang je status 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.

Headers
POST /api/v1/payments
Authorization: Bearer wp_live_...
Content-Type: application/json
Idempotency-Key: order-12345-payment-attempt-1
VereisteWaarde
Lengte8-255 karakters
Geldigheid24 uur
ScopePer API key + endpoint
Bij een duplicate request wordt de originele response teruggegeven met headerIdempotent-Replayed: true.

Payments

Endpoints voor het aanmaken en beheren van betalingen.

POST/api/v1/payments

Maak een nieuwe Payment Intent aan. Retourneert een clientSecret voor gebruik met Stripe Elements op de frontend.

Request Body

ParameterTypeVereistBeschrijving
amountnumberJaBedrag in centen (min: 50, max: 99999999)
currencystringNeeValuta: eur, usd, gbp. Default: eur
descriptionstringJaBeschrijving (1-1000 karakters)
metadataobjectNeeExtra data (max 8KB)
paymentMethodTypesstring[]NeeBetaalmethodes voor checkout. Alleen methodes die actief zijn in je Stripe capabilities werken. Default: ["card", "ideal", "sepa_debit"]
captureMethodstringNeeautomatic 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.

cardidealsepa_debitbancontactsofort
Request Body
{
  "amount": 5000,
  "currency": "eur",
  "description": "Order #12345",
  "metadata": {
    "orderId": "12345",
    "customerEmail": "klant@example.com"
  },
  "paymentMethodTypes": ["card", "ideal"]
}
Response (200 OK)
{
  "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"
  }
}
GET/api/v1/payments

Haal een lijst op van alle betalingen voor je account.

Query Parameters

ParameterTypeDefaultBeschrijving
limitnumber10Aantal resultaten (max: 100)
starting_afterstring-Cursor voor pagination (Firestore document ID uit vorige response: pagination.nextCursor)
test_modeboolean-Optioneel filter: true of false
Response (200 OK)
{
  "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
  }
}
GET/api/v1/payments/:id

Haal details op van een specifieke betaling. De :id kan een document ID of Stripe Payment Intent ID (pi_...) zijn.

Response (200 OK)
{
  "success": true,
  "data": {
    "id": "doc123",
    "paymentIntentId": "pi_abc123",
    "amount": 5000,
    "currency": "eur",
    "status": "succeeded",
    "description": "Order #12345",
    "createdAt": "2025-12-16T10:30:00.000Z"
  }
}
POST/api/v1/payments/:id

Voer een actie uit op een bestaande Payment Intent. Gebruik dit vooral voor betalingen met captureMethod op manual.

Request Body

ParameterTypeVereistBeschrijving
actionstringJacapture of cancel
Request Body
{
  "action": "capture"
}
Response (200 OK)
{
  "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.

Vereist: Terminal moet ingeschakeld zijn voor je account via het WolffPay dashboard. De reader moet geregistreerd en actief zijn. Gebruik daarnaast altijd een backend endpoint voor ConnectionTokens. Voor WisePad 3 (WPC32): gebruik Stripe Terminal SDK met Bluetooth discovery; USB is alleen voor opladen.
POST/api/v1/terminal/connection-token

Maak een kortlevende Stripe Terminal ConnectionToken aan voor je client app. Dit endpoint is geauthenticeerd met je WolffPay API key.

Request Body (optioneel)
{
  "locationId": "tml_abc123"
}
Response (200 OK)
{
  "success": true,
  "data": {
    "secret": "pst_test_terminal_connection_token",
    "locationId": "tml_abc123"
  }
}
POST/api/v1/payments/terminal

Alle terminal-acties gaan via dit endpoint. Het action veld bepaalt welke actie wordt uitgevoerd.

Beschikbare Acties

ActionBeschrijving
create_intentMaak een card_present Payment Intent aan (met Connect fee split)
collect_payment_methodLaat de reader wachten op kaartpresentatie (smart readers)
process_paymentVerwerk de betaling op de reader (smart readers)
collect_and_processCombinatie: collect + process in één stap (smart readers)
cancel_reader_actionAnnuleer de huidige actie op de reader (smart readers)
cancel_paymentAnnuleer de Payment Intent
capture_paymentCapture een handmatig geautoriseerde Payment Intent (captureMethod: manual)

Stap 1: create_intent

ParameterTypeVereistBeschrijving
actionstringJacreate_intent
amountnumberJaBedrag in centen (min: 50)
currencystringNeeAlleen eur (default)
descriptionstringJaBeschrijving (1-1000 karakters)
metadataobjectNeeExtra data (max 8KB)
captureMethodstringNeeautomatic (default) of manual
Request Body (create_intent)
{
  "action": "create_intent",
  "amount": 5000,
  "currency": "eur",
  "description": "Bestelling #456",
  "metadata": {
    "order_id": "456"
  }
}
Response (200 OK)
{
  "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.

ParameterTypeVereistBeschrijving
actionstringJacollect_and_process
readerIdstringJaStripe Terminal Reader ID (tmr_...)
paymentIntentIdstringJaPayment Intent ID uit stap 1 (pi_...)
Request Body (collect_and_process)
{
  "action": "collect_and_process",
  "readerId": "tmr_abc123",
  "paymentIntentId": "pi_3abc456"
}
Response (200 OK)
{
  "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.

Annuleren voorbeelden
// 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.

Manual capture voorbeeld
{ "action": "capture_payment", "paymentIntentId": "pi_3abc456" }
Typische flow:
  1. Roep /api/v1/terminal/connection-token aan vanuit je app backend
  2. Roep create_intent aan met bedrag + beschrijving
  3. Roep collect_and_process aan met reader ID + payment intent ID
  4. Klant houdt pas/telefoon tegen de reader, en bij manual capture roep je daarna capture_payment aan

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.

POST/api/v1/refunds

Maak een refund aan voor een bestaande betaling. Zonder amount wordt het volledige bedrag terugbetaald.

Request Body

ParameterTypeVereistBeschrijving
paymentIntentIdstringJaStripe Payment Intent ID (pi_...)
amountnumberNeePartial refund bedrag in centen
reasonstringNeeduplicate, fraudulent, of requested_by_customer
metadataobjectNeeExtra data (wordt meegestuurd naar Stripe; max 8KB aanbevolen)
Request Body (Partial Refund)
{
  "paymentIntentId": "pi_abc123",
  "amount": 2500,
  "reason": "requested_by_customer"
}
Response (200 OK)
{
  "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"
  }
}
GET/api/v1/refunds

Haal een lijst op van refunds. Optioneel te filteren op payment.

Query Parameters

ParameterTypeBeschrijving
limitnumberAantal resultaten (default: 10, max: 100)
paymentIntentIdstringFilter op specifieke betaling
starting_afterstringCursor voor pagination (Firestore document ID uit vorige response: pagination.nextCursor)
Response (200 OK)
{
  "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.

Error Response Format
{
  "success": false,
  "error": "Description of what went wrong"
}

HTTP Status Codes

StatusBetekenis
400Bad Request - Ongeldige parameters of validatiefout
401Unauthorized - Ongeldige of missende API key
403Forbidden - Account inactief of terminal uitgeschakeld
404Not Found - Resource niet gevonden
429Too Many Requests - Rate limit overschreden
500Internal Server Error - Server probleem

Veelvoorkomende Errors

ErrorOorzaak
Invalid amount. Minimum is 50 cents.Bedrag te laag (min €0.50)
Idempotency-Key header is requiredPOST request zonder idempotency key
Rate limit exceededMeer dan 100 requests/minuut
Stripe Connect not configuredOnboarding 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 TypeBeschrijving
payment.succeededBetaling succesvol afgerond
payment.failedBetaling mislukt
payment.refundedTerugbetaling verwerkt (volledig of gedeeltelijk)

Webhook Payload

Webhook Event 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.

Webhook Request Headers
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",
  ...
}
Security: Webhooks zonder geldige signature moeten worden afgewezen met status 401. Dit voorkomt dat ongeautoriseerde requests worden verwerkt.

Verificatie Algoritme

De signature header heeft het formaat: t=timestamp,v1=signature

Signature Verificatie (Node.js)
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

Complete Webhook Handler
// 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:

Local Development Setup
# Start ngrok tunnel
ngrok http 3000

# Output: https://abc123.ngrok.io

# Update webhook URL in WolffPay dashboard:
# https://abc123.ngrok.io/api/webhooks/wolffpay
Best Practices:
  • 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)