Webhook Event Engine
The webhook engine delivers real-time server-to-server notifications to merchant endpoints whenever a payment changes state. Because banking APIs are asynchronous, webhooks allow merchant backends to fulfil orders (release goods, update ledgers, notify customers) without polling.
bopen-devEverything below describes the engine's designed behaviour. The code is deployed and the background worker runs, but bopen.webhook_deliveries currently has zero rows in bopen-dev — no webhook has ever actually fired, because no payment has completed its full lifecycle in this environment yet. The retry schedule, HMAC signing, and dead-letter replay flow are unverified against real traffic here.
Security — HMAC-SHA256 signature
Every outbound webhook request includes a cryptographic signature:
X-BOpenBanking-Signature: <hex-encoded HMAC-SHA256>
The signature is computed as:
HMAC-SHA256(webhook_signing_secret, raw_request_body)
The webhook_signing_secret is a 256-bit (64-char hex) key unique to each merchant, visible and rotatable in the Acquirer Portal under Developer Tools → Webhook Signing Secret.
Merchant-side verification (Node.js)
const crypto = require('crypto');
function verifyWebhook(rawBody, signature, secret) {
const computed = crypto
.createHmac('sha256', secret)
.update(rawBody) // raw bytes — NOT JSON.parse(rawBody)
.digest('hex');
// constant-time comparison prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(computed),
Buffer.from(signature),
);
}
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-bopenbanking-signature'];
if (!verifyWebhook(req.body, sig, process.env.BOPEN_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
// process event.eventType ...
res.json({ received: true });
});
Always verify using the raw request body bytes, before any JSON parsing. Parsing and re-serialising will change byte order and invalidate the signature.
Payload envelope
All webhooks share the same outer envelope:
{
"eventId": "evt_1751967852123_a8f3c21b",
"eventType": "payment.settled",
"apiVersion": "v1",
"createdAt": "2026-07-08T10:45:22Z",
"data": {
"object": "transaction",
"id": "tx_a1b2c3d4",
"endToEndId": "BOPa1b2c3d4e5f67890abcdef123456",
"consentId": "con_...",
"merchantId": "mer_...",
"financials": {
"grossAmount": "150.00",
"feeAmount": "1.70",
"netAmount": "148.30",
"currency": "GBP"
},
"status": "SETTLED",
"bankDetails": {
"providerId": "barclays"
},
"failure": null
}
}
Use eventId as an idempotency key on your endpoint — if our platform retries a delivery, the payload is identical. Respond with HTTP 200 immediately when you detect a duplicate eventId.
Event catalogue
| Event type | Trigger | Recommended merchant action |
|---|---|---|
payment.initiated | Payer opens the widget and an Open Banking consent record is locked | Log only — useful for funnel analysis |
payment.authorized | Payer completes biometric SCA at their bank | Update order to "Awaiting Funds" |
payment.settled | Funds arrive in the clearing pool | Fulfil order — release goods, provision services |
payment.failed | Bank rejects execution (insufficient funds, timeout, etc.) | Re-open cart, notify customer |
payment.refunded | Merchant initiates a refund via portal or API | Update order to "Refunded" |
Failure payload detail
{
"eventId": "evt_1751967900000_f001ab",
"eventType": "payment.failed",
"apiVersion": "v1",
"createdAt": "2026-07-08T10:45:00Z",
"data": {
"object": "transaction",
"id": "tx_c3d4e5f6",
"endToEndId": "BOPc3d4e5...",
"status": "FAILED",
"failure": {
"code": "ACCESS_DENIED",
"reason": "Authorisation cancelled at the bank.",
"source": "ASPSP_REJECTION"
}
}
}
failure.code maps directly from payment_intents.failure_code (ISO-standard bank error codes).
Delivery guarantees & retry schedule
The engine uses a transactional outbox pattern — delivery records are inserted into bopen.webhook_deliveries inside the same database transaction as the payment state change. A background worker (running every 5 seconds inside the ECS task) processes pending deliveries.
Timeout rule: any endpoint that does not respond with a 2xx status within 5 seconds is treated as a failure.
| Attempt | Delay after previous failure |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours (final) |
After 6 consecutive failures the delivery status transitions to DEAD. A red alert banner appears on the Acquirer Portal → Developer Tools page. Merchants click Replay to re-queue once their endpoint is back online.
Jitter (±20%) is applied to each retry delay to prevent thundering herd.
Idempotency
Merchant endpoints must use the eventId field as an idempotency key:
- On receipt, check whether
eventIdhas already been processed. - If yes: respond
200 OKimmediately without re-processing. - If no: process, then record the
eventIdas processed.
This protects against network blips causing duplicate delivery. bopen guarantees at-least-once delivery — exactly-once delivery is the merchant's responsibility via idempotency.
Managing webhooks via the portal
In the Acquirer Portal → Developer Tools:
- Register endpoint — add a webhook URL and subscribe to specific event types
- Test webhook — sends a mock
payment.settledpayload to verify routing - Signing secret — view masked secret, copy to clipboard, or rotate
- Delivery history — shows the last 50 deliveries with status, HTTP code, attempt count
- Replay — visible on DEAD deliveries; resets
attempt_count = 0and re-queues
Managing webhooks via API
| Endpoint | Description |
|---|---|
GET /api/v1/portal/webhook-secret | Returns masked signing secret and Node.js snippet |
POST /api/v1/portal/webhook-secret/rotate | Generates and stores a new signing secret |
GET /api/v1/portal/webhooks | Lists last 50 deliveries for the authenticated merchant |
POST /api/v1/portal/webhooks/:id/replay | Re-queues a DEAD delivery |
All portal endpoints require a valid Keycloak Bearer token.
Infrastructure
The engine is implemented in services/payment-service/src/webhook.rs:
WebhookEngine::schedule(pool, merchant_id, payment_id, event_type, data)— inserts the outbox rowWebhookEngine::process_due(pool)— called every 5 s by a background Tokio task; usesFOR UPDATE SKIP LOCKEDto safely handle multiple ECS tasksWebhookEngine::sign_payload(secret, body)— HMAC-SHA256 via thehmac+sha2crates
The webhook_deliveries table is covered by an index on (next_retry_at) WHERE status IN ('PENDING','FAILED') for efficient worker queries.