Skip to main content

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.

Unexercised in bopen-dev

Everything 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 });
});
warning

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 typeTriggerRecommended merchant action
payment.initiatedPayer opens the widget and an Open Banking consent record is lockedLog only — useful for funnel analysis
payment.authorizedPayer completes biometric SCA at their bankUpdate order to "Awaiting Funds"
payment.settledFunds arrive in the clearing poolFulfil order — release goods, provision services
payment.failedBank rejects execution (insufficient funds, timeout, etc.)Re-open cart, notify customer
payment.refundedMerchant initiates a refund via portal or APIUpdate 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.

AttemptDelay after previous failure
1Immediate
21 minute
35 minutes
430 minutes
52 hours
66 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:

  1. On receipt, check whether eventId has already been processed.
  2. If yes: respond 200 OK immediately without re-processing.
  3. If no: process, then record the eventId as 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.settled payload 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 = 0 and re-queues

Managing webhooks via API

EndpointDescription
GET /api/v1/portal/webhook-secretReturns masked signing secret and Node.js snippet
POST /api/v1/portal/webhook-secret/rotateGenerates and stores a new signing secret
GET /api/v1/portal/webhooksLists last 50 deliveries for the authenticated merchant
POST /api/v1/portal/webhooks/:id/replayRe-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 row
  • WebhookEngine::process_due(pool) — called every 5 s by a background Tokio task; uses FOR UPDATE SKIP LOCKED to safely handle multiple ECS tasks
  • WebhookEngine::sign_payload(secret, body) — HMAC-SHA256 via the hmac + sha2 crates

The webhook_deliveries table is covered by an index on (next_retry_at) WHERE status IN ('PENDING','FAILED') for efficient worker queries.