Webhooks
De Order Webhook v3 voegt HMAC signature verification en drie nieuwe eventtypes toe bovenop alle bestaande events. De huidige webhook blijft ongewijzigd werken — v3 is opt-in.
“v3” verwijst naar de webhook-versie, niet naar de API-versie. Alle Webhook v3-eindpunten gebruiken het voorvoegsel /v2/.
Elke v3-webhook bevat drie beveiligingsheaders:
Header | Purpose |
| HMAC-SHA256-handtekening (hexadecimaal gecodeerd) |
| Unix-tijdstempel (seconden) — afwijzen als ouder dan 5 minuten |
| Uniek leverings-ID — blijft bij herhalingspogingen hetzelfde; gebruik dit voor het verwijderen van dubbele gegevens |
Kenmerkende formule: HMAC-SHA256(timestamp + raw_body, secret) — de uitvoer bestaat uit kleine letters in hexadecimale notatie, zonder voorvoegsel.
De onbewerkte HTTP-body moet worden gehasht — JSON mag nooit worden geparseerd en opnieuw geserialiseerd vóór de verificatie.
Handtekeningcontrole
Herhalingen (retries)
Billink probeert mislukte leveringen tot 3 keer opnieuw te verzenden met een exponentiële wachttijd (exponential backoff). Elke herpoging gebruikt dezelfde X-Billink-Webhook-Id. Na 3 opeenvolgende mislukte pogingen wordt er een e-mailnotificatie verzonden.
Snel starten:
Stap 1: Genereer een ondertekeningsgeheim
POST https://api-staging.billink.nl/v2/client/webhook-v3/secret/generateBody:
{ "billinkUsername": "your_username", "billinkID": "your_billink_id"}Response:
{ "status": "success", "message": "Webhook V3 secret is generated successfully", "secret": "npHiAuRps5S+nTsDL2L663zmm9gfiP3wtmMDgbQNDLg="}Sla dit geheim veilig op — je hebt het nodig om webhook-handtekeningen te verifiëren. Het geheim is een base64-gecodeerde string. Eén geheim wordt gebruikt voor alle v3-webhooks (zowel session als order).
Stap 2: Registreer je v3 webhook-URL’s
Order webhook:
POST https://api-staging.billink.nl/v2/client/webhook-v3/setBody:
{ "billinkUsername": "your_username", "billinkID": "your_billink_id", "url": "https://your-domain.com/webhook/order"}Session webhook:
POST https://api-staging.billink.nl/v2/session/webhook-v3/setBody:
{ "billinkUsername": "your_username", "billinkID": "your_billink_id", "url": "https://your-domain.com/webhook/session"}Response (both):
{ "status": "success", "message": "Webhook has been set"}Stap 3: Implementeer signature verification
Elke v3 webhook bevat drie headers:
Signature constructie: HMAC-SHA256(timestamp + payload, secret) waarbij timestamp de Unix timestamp string is en payload de ruwe request body bytes zijn, direct aan elkaar gekoppeld (zonder scheidingsteken). De output is lowercase hex, zonder sha256= prefix.
Belangrijk: je moet de ruwe HTTP request body exact zo hashen als die wordt ontvangen — parse de JSON niet en serialize hem niet opnieuw. Verschillende talen serialiseren JSON op verschillende manieren (bijv. Node.js escapt / niet, PHP wel), waardoor de signature ongeldig wordt.
Stap 4: Schakel oude webhooks uit (optioneel)
Let op: je kunt oude en v3 webhooks tegelijk gebruiken tijdens het testen. Events worden dan dubbel verzonden als beide ingeschakeld zijn.
Order webhook:
POST https://api-staging.billink.nl/v2/client/webhook/disableSession webhook:
POST https://api-staging.billink.nl/v2/session/webhook/disableBody (beide):
{ "billinkUsername": "your_username", "billinkID": "your_billink_id"}Signature Verification — Codevoorbeelden
PHP
<?php
$secret = 'npHiAuRps5S+nTsDL2L663zmm9gfiP3wtmMDgbQNDLg=';
// BELANGRIJK: gebruik de ruwe HTTP body — NIET json_decode direct —
// zodat je de originele bytes behoudt.
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_BILLINK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_BILLINK_TIMESTAMP'] ?? '';
// 1. Replay-bescherming
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(403);
exit('Timestamp too old');
}
// 2. Verifieer signature tegen RAW body bytes
$expected = hash_hmac('sha256', $timestamp . $rawBody, $secret);
// 3. Timing-safe vergelijking
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit('Invalid signature');
}
// 4. Parseer pas na verificatie
$event = json_decode($rawBody, true);
http_response_code(200);
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = 'npHiAuRps5S+nTsDL2L663zmm9gfiP3wtmMDgbQNDLg=';
// BELANGRIJK: gebruik express.raw — NIET express.json() —
// zodat req.body een Buffer is met de originele bytes.
app.post('/webhook/order',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-billink-signature'] || '';
const timestamp = req.headers['x-billink-timestamp'] || '';
// 1. Replay-bescherming
if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
return res.status(403).send('Timestamp too old');
}
// 2. Verifieer signature tegen RAW body bytes
const rawBody = req.body.toString('utf8');
const expected = crypto
.createHmac('sha256', SECRET)
.update(timestamp + rawBody)
.digest('hex');
const sigBuf = Buffer.from(signature, 'utf8');
const expBuf = Buffer.from(expected, 'utf8');
if (sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(403).send('Invalid signature');
}
// 3. Alleen parsen na verificatie
const event = JSON.parse(rawBody);
res.status(200).end();
}
);
app.listen(3000);Python
import hmac, hashlib, time, json
SECRET = 'npHiAuRps5S+nTsDL2L663zmm9gfiP3wtmMDgbQNDLg='
def verify_billink_webhook(raw_body: str, headers: dict, secret: str):
signature = headers.get('X-Billink-Signature', '')
timestamp = headers.get('X-Billink-Timestamp', '')
# 1. Replay-bescherming
if abs(time.time() - int(timestamp)) > 300:
raise ValueError('Timestamp too old')
# 2. Verifieer signature
expected = hmac.new(
secret.encode(),
f'{timestamp}{raw_body}'.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError('Invalid signature')Flask usage:
from flask import Flask, request, abort
app = Flask(__name__)
@app.post('/webhook/order')
def webhook():
raw_body = request.get_data(as_text=True) # Ruwe body, niet request.json
try:
verify_billink_webhook(raw_body, dict(request.headers), SECRET)
except ValueError as e:
abort(403, str(e))
event = json.loads(raw_body)
return '', 200FastAPI usage:
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post('/webhook/order')
async def webhook(request: Request):
raw_body = (await request.body()).decode('utf-8') # Ruwe body
try:
verify_billink_webhook(raw_body, dict(request.headers), SECRET)
except ValueError as e:
raise HTTPException(status_code=403, detail=str(e))
event = json.loads(raw_body)
return {}Alle event types
Order events
Event | Beschrijving | Typisch gebruik |
order_placed | Klant heeft succesvol een order geplaatst. | Markeer order als aangemaakt. |
order_workflow_started | De order is in het interne verwerkingsproces opgenomen. | Order kan als gestart worden weergegeven. |
partial_payment_added | Er is een deelbetaling voor de order ontvangen. | Haal de nieuwe ordergegevens op om het openstaande bedrag bij te werken. |
order_paid | De order is volledig betaald. | Markeer order als betaald. |
partial_credit_added | Er is een gedeeltelijke creditering of terugbetaling toegepast op de order. | Haal de nieuwe ordergegevens op om het openstaande bedrag bij te werken. |
order_fully_accredited | Er is een volledige creditering of terugbetaling toegepast op de order. | Markeer order als volledig gecrediteerd in het systeem. |
customer_fully_paid | De klant heeft alle openstaande bedragen voldaan. | Markeer order als betaald. |
order_on_hold | De order staat tijdelijk on hold (bijv. in afwachting van beoordeling of geschilproces). | Markeer order als gepauzeerd. |
dispute_created | Er is een geschil geopend voor de order (bijv. de klant betwist een bedrag). | Pauzeer de fulfillment en volg de geschilstatus. |
dispute_resolved | Een eerder geopend geschil is opgelost. | Hervat de normale orderverwerking. |
retrocession_credit_applied | Er is een retrocessiekrediet toegepast op de order (krediet teruggestort aan de merchant door Billink). | Pas het ordersaldo aan en werk de boekhouding bij. |
Session events
Session-webhooks worden alleen verzonden bij statuswijzigingen van een bestaande sessie. Het aanmaken van een nieuwe sessie triggert geen webhook — het eerste event dat je ontvangt komt overeen met de eerste statusovergang (bijv. order_created wanneer de koper de checkout afrondt, of cancelled als de sessie wordt geannuleerd).
Status | Beschrijving | Typisch gebruik |
session_active | De sessie is succesvol geinitialiseerd en is momenteel actief. | De sessie is gestart en de klant doorloopt het betaalproces. |
cancelled | De klant heeft de sessie handmatig geannuleerd of de checkout gesloten. | Stop het volgen van de sessie en markeer deze als geannuleerd. |
failed | De sessie is mislukt door een technische of validatiefout (bijv. betaalmethode niet beschikbaar, ongeldige gegevens). | Toon een foutmelding of log de fout voor een nieuwe poging. |
session_expired | De sessie is automatisch verlopen na een timeout (1 uur) zonder het afronden van de checkout. | Markeer sessie als verlopen en annuleer gerelateerde lopende processen. |
order_created | De checkout is succesvol afgerond en er is een order aangemaakt. | Bevestig de order en start de fulfillment in uw systeem. |
Webhook Payload Formats
Payload van order webhook
Alle order-events gebruiken dezelfde payload-structuur (ongewijzigd ten opzichte van de huidige webhook):
{ "order_id": 12345, "invoice_number": "INV-2026-001", "invoice_number_clean": "INV2026001", "workflow_id": 67, "event": "order_paid", "timestamp": "2026-04-07 10:00:00"}Veld | Type | Beschrijving |
order_id | integer | Billink order ID |
invoice_number | string | factuurnummer |
invoice_number_clean | string | factuurnummer zonder speciale tekens |
workflow_id | integer | Workflow instance ID |
event | string | eventtype (zie eventtabel hierboven) |
timestamp | string | Tijdstempel van het event (Y-m-d H:i:s) |
Payload van session webhook
Dit formaat is hetzelfde voor zowel v2- als v3-session webhooks.
Session-webhooks gebruiken een andere payload-structuur:
{ "transactionId": "d290f1ee-6c54-4b01-90e6-d701748f0851", "billinkInvoiceNumber": "INV-2026-001", "invoiceNumber": "ORDER-2026-001", "status": "order_created"}Veld | Type | Beschrijving |
transactionId | string | Sessie transactie-ID |
billinkInvoiceNumber | string | Billink invoice number |
invoiceNumber | string | Billink factuurnummer |
status | string | Sessie status (zie session events tabel hierboven) |
On this page
- Webhooks