Webhooks
The Order Webhook v3 adds HMAC signature verification and three new event types on top of all existing events. The current webhook continues to work unchanged — v3 is opt-in.
NOTE: "v3" refers to the webhook version, not the API version. All Webhook v3 endpoints use the /v2/ prefix.
Every v3 webhook includes three security headers:
Header | Purpose |
| HMAC-SHA256 signature (hex-encoded) |
| Unix timestamp (seconds) — reject if older than 5 min |
| Unique delivery ID — stable across retries, use for deduplication |
Signature formula: HMAC-SHA256(timestamp + raw_body, secret) — output is lowercase hex, no prefix.
Must hash raw HTTP body — never parse and re-serialize JSON before verification.
Signature verification
Retries
Billink retries failed deliveries up to 3 times with exponential backoff. Each retry carries the same X-Billink-Webhook-Id. After 3 consecutive failures an email notification is sent.
Quick Start:
Step 1: Generate a signing secret
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="}Save this secret securely — you'll need it to verify webhook signatures. The secret is a base64-encoded string. One secret is used for all v3 webhooks (both session and order).
Step 2: Register your v3 webhook URLs
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"}Step 3: Implement signature verification
Every v3 webhook includes three headers:
Signature construction: HMAC-SHA256(timestamp + payload, secret) where timestamp is the Unix timestamp string and payload is the raw request body bytes, concatenated directly (no separator). Output is lowercase hex, no sha256= prefix.
Important: You must hash the raw HTTP request body exactly as received — do not parse and re-serialize JSON. Different languages serialize JSON differently (e.g. Node.js doesn't escape /, PHP does), which will break the signature.
Step 4: Disable old webhooks (optional)
Note: You can run both old and v3 webhooks simultaneously during testing. Events will be sent twice if both are enabled.
Order webhook:
POST https://api-staging.billink.nl/v2/client/webhook/disableSession webhook:
POST https://api-staging.billink.nl/v2/session/webhook/disableBody (both):
{ "billinkUsername": "your_username", "billinkID": "your_billink_id"}Signature Verification — Code Examples
PHP
<?php
$secret = 'npHiAuRps5S+nTsDL2L663zmm9gfiP3wtmMDgbQNDLg='; // from Step 1
// Read RAW body — do NOT use $_POST or json_decode before verification
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_BILLINK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_BILLINK_TIMESTAMP'] ?? '';
// 1. Reject requests older than 5 minutes (replay protection)
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(403);
exit('Timestamp too old');
}
// 2. Verify signature
$expected = hash_hmac('sha256', $timestamp . $payload, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit('Invalid signature');
}
// 3. Webhook verified — now you can parse
$event = json_decode($payload, true);
http_response_code(200);
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = 'npHiAuRps5S+nTsDL2L663zmm9gfiP3wtmMDgbQNDLg=';
// IMPORTANT: use express.raw — NOT express.json() —
// so req.body is a Buffer with the original 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 protection
if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
return res.status(403).send('Timestamp too old');
}
// 2. Verify signature against 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. Parse only after verification
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 protection
if abs(time.time() - int(timestamp)) > 300:
raise ValueError('Timestamp too old')
# 2. Verify 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) # RAW body, not 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') # RAW 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 {}All Event Types
Order events
Event | Description | Typical Use Case |
order_placed | Customer successfully placed an order. | Mark order as created. |
order_workflow_started | The order entered internal processing workflow. | Order can be shown as started. |
partial_payment_added | A partial payment for the order was received. | Get the new order details to adjust the outstanding amount. |
order_paid | The order has been fully paid. | Mark order as paid. |
partial_credit_added | A partial credit or refund was applied to the order. | Get the new order details to adjust the outstanding amount. |
order_fully_accredited | A full credit or refund was applied to the order. | Mark order as fully accredited in system. |
customer_fully_paid | The customer has cleared all outstanding balances. | Mark order as paid. |
order_on_hold | The order is temporarily on hold (e.g. pending review, dispute process). | Mark order as paused. |
dispute_created *new v3 event | A dispute has been opened for the order (e.g. the customer contests a charge). | Pause fulfillment and track the dispute status. |
dispute_resolved *new v3 event | A previously opened dispute has been resolved. | Resume normal order processing. |
retrocession_credit_applied *new v3 event | A retrocession credit has been applied to the order (credit returned to the merchant by Billink). | Adjust the order balance and update accounting record |
Session events
Session webhooks are fired only on status changes of an existing session. Creating a new session does not emit a webhook — the first event you receive will correspond to the first status transition (e.g. order_created when the buyer completes checkout, or cancelled if the session is cancelled).
Status | Description | Typical Use Case |
session_active | The session was successfully initialized and is currently active. | The session has started and the customer interacts with the payment flow. |
cancelled | The customer cancelled the session manually or closed the checkout. | Stop tracking the session and mark it as cancelled. |
failed | The session failed due to a technical or validation error (e.g. payment method unavailable, invalid data). | Display a failure message or log the error for retry. |
session_expired | The session expired automatically after a timeout (1 hour) without completing checkout. | Mark session as expired and cancel related pending processes. |
order_created | Checkout was successfully completed and an order has been created. | Confirm the order and trigger fulfillment in your system. |
Webhook Payload Formats
Order webhook payload
All order events use the same payload structure (unchanged from current 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"}Field | Type | Description |
order_id | integer | Billink order ID |
invoice_number | string | Invoice number |
invoice_number_clean | string | Invoice number without special characters |
workflow_id | integer | Workflow instance ID |
event | string | Event type (see event table above) |
timestamp | string | Event timestamp (Y-m-d H:i:s) |
Session webhook payload
This format is the same for both v2 and v3 session webhooks.
Session webhooks use a different payload structure:
{ "transactionId": "d290f1ee-6c54-4b01-90e6-d701748f0851", "billinkInvoiceNumber": "INV-2026-001", "invoiceNumber": "ORDER-2026-001", "status": "order_created"}Field | Type | Description |
transactionId | string | Session transaction ID |
billinkInvoiceNumber | string | Billink invoice number |
invoiceNumber | string | Merchant order number |
status | string | Session status (see session events table above) |
What made this section helpful for you?
What made this section unhelpful for you?
On this page
- Webhooks