Engineering

Out-of-Order NIP Settlements: Stop Polling Core Banks and Build a Debezium CDC Ledger

O
Oluwaseun AlabiTechnical Director
October 1, 202618 min read
Out-of-Order NIP Settlements: Stop Polling Core Banks and Build a Debezium CDC Ledger

Cron-based polling of Nigerian core bank APIs under heavy NIP network strain causes db connection exhaustion, out-of-order credits, and ghost balance updates. We explain why Change Data Capture (CDC) via PostgreSQL logical decoding and Redis Streams is the architecture that scales.

Every engineering team in Lagos building on top of virtual accounts eventually hits the same wall. Your users generate dynamic virtual accounts to fund their wallets via bank transfer. A user opens their mobile banking app, selects GTBank or Zenith, enters your virtual account number, and hits send. Under normal conditions, NIBSS (Nigeria Inter-Bank Settlement System) processes the NIP transaction, routes it to your sponsor bank (Wema, Providus, Sterling, or Fidelity), and their webhook hits your API gateway within three seconds. Your webhook handler writes a record, credits the wallet, and the user sees their updated balance.

Then peak hours hit—payday Friday, 6:00 PM. NIBSS latency spikes. Your sponsor bank's inbound webhook queue backs up by 45 minutes. Worse, webhooks start arriving completely out of sequence: settlement credit webhooks land before transaction initiation payloads, or final reversal webhooks land after your backend has already processed an outdated 'PENDING' status request.

The standard response from engineering teams is to introduce cron-based API polling. They write a background job that runs every two minutes, queries the database for all pending virtual account allocations, and hammers the sponsor bank's GET /v1/transaction/status endpoint. Within weeks, this pattern breaks down completely. Core bank endpoints return 504 Gateway Timeouts, API rate limits throttle your servers, database connection pools exhaust, and worst of all, duplicate state transitions trigger ghost credits.

Polling sponsor bank APIs to reconcile NIP settlement delays is a dangerous architecture antipattern. The durable fix is an event-driven ledger engine powered by Change Data Capture (CDC) via PostgreSQL Write-Ahead Logging (WAL) and Redis Streams.

The Core Bank Polling Trap in Nigerian Virtual Account Infrastructure

To understand why polling fails, you must understand the infrastructure path of a NIP transfer. When a sender authorizes a transaction, the transfer passes through several distinct hops before hitting your application ledger:

  1. Originating Bank Core Banking Application -> NIBSS NIP Switch
  2. NIBSS Switch -> Receiving (Sponsor) Bank Switch
  3. Receiving Bank Switch -> Sponsor Bank Core Banking Ledger
  4. Sponsor Bank Webhook Engine -> Your API Gateway Endpoint
  5. Your API Gateway -> Your Application Database

When network congestion occurs at hop 2 or hop 3, the sponsor bank's core banking ledger may update, but their webhook dispatch service (hop 4) becomes overwhelmed. If your system relies on polling GET /v1/transaction/status at hop 4 during this period, you are making HTTP requests against an API wrapper that reads from the same degraded internal queues.

During a major NIBSS slowdown, a simple batch job polling 10,000 pending transactions every 120 seconds creates a massive thundering herd problem. 10,000 outgoing HTTP requests bombard your partner bank's gateway. The bank responds with HTTP 429 Too Many Requests or HTTP 504 Gateway Timeout. Your background workers catch the exception, retry, and exhaust your database connection pool while holding open write transactions.

When we analyzed database bottlenecks in high-volume payment pipelines, we found that over 60% of locked database connections were held by long-running polling cron jobs waiting for third-party HTTP timeouts. Running heavy polling infrastructure on expensive cloud infrastructure quickly balloons server bills. Teams that succeeded in Slashing AWS Infra Costs by 68% with Hetzner K3s and WireGuard: A Lagos Fintech Case Study realized that infrastructure cost reduction requires eliminating resource-hungry cron jobs first.

How Out-of-Order Webhooks Cause Ghost Credits and Double-Fulfillment

The second major failure mode of polling-based systems is state overwrite race conditions. Consider this sequence of events:

  1. 14:00:00 — User transfers ₦50,000 to virtual account 9920123456.
  2. 14:00:02 — Webhook #1 (STATUS_PENDING) is dispatched by the bank but delayed in transit.
  3. 14:05:00 — The transaction settles on NIBSS. Webhook #2 (STATUS_SUCCESS, session_id: 000013240508...) is dispatched and arrives instantly at 14:05:01.
  4. 14:05:02 — Your application receives Webhook #2, updates transaction status to SUCCESS, and credits the wallet balance by ₦50,000.
  5. 14:05:30 — Delayed Webhook #1 (STATUS_PENDING) finally lands at your API gateway.
  6. 14:06:00 — A scheduled polling worker queries the bank, gets a temporary timeout, falls back to the local database state, or processes Webhook #1's raw payload, overwriting status back to PENDING or triggering a duplicate re-evaluation.

If your state transitions are not deterministic and strict, delayed webhooks will overwrite final states. When building transactional engines that interface with Nigerian payment rails, developers often try to handle concurrency using basic database locks. While we previously discussed Preventing Double-Crediting in Paystack Webhooks: Postgres Advisory Locks vs. Redis Redlock, virtual account settlements present a different set of challenges because state transitions arrive completely out of sequence across independent network paths.

| Failure Scenario | Traditional Polling Behavior | CDC & State Machine Behavior | | :--- | :--- | :--- | | Sponsor Bank Gateway 504 Timeout | Workers retry, exhaust DB pool, crash worker nodes | Off-line stream processing; no HTTP call required for state sync | | Out-of-Order Webhook Delivery | Pending payload overwrites Success state; wallet credited twice | Strict state sequence validation ignores outdated payload versions | | NIBSS Settlement Delay (>2 hours) | Polling job times out; flags transaction as 'Failed' prematurely | Immutable ledger appends event; auto-reconciles whenever WAL emits record | | Database Lock Contention | High (SELECT FOR UPDATE during external HTTP call) | Zero HTTP calls inside DB transactions; append-only writes |

Why Traditional Task Queues Fail Under NIBSS Network Congestion

A common counterargument raised by engineers is: "We don't use simple cron jobs; we use BullMQ / Celery with exponential backoff delay jobs."

While task queues like BullMQ or Celery are superior to bare setInterval crons, they still suffer from fundamental architectural flaws when applied to payment settlement reconciliation:

  1. Queue Bloat and RAM Exhaustion: When NIBSS experiences an extended outage lasting 3 to 6 hours, hundreds of thousands of retry jobs pile up in Redis or SQS memory. Backoff timers expire simultaneously, creating massive job spikes that overwhelm both application memory and the sponsor bank's rate limiters upon recovery.
  2. Stale Core Bank Responses: During heavy backlog resolution, sponsor bank status endpoints frequently return stale data. A GET /v1/transaction/status request might return PENDING at minute 15, even though the transaction succeeded at minute 12 inside the bank's internal core clearing house. If your queued task trusts this PENDING response and updates your system state, you introduce artificial latency for the end user.
  3. Lack of Single Source of Truth: A task queue message is an ephemeral command ("Go check this status"), not a verified state facts engine. If the queue worker crashes, or if Redis drops key persistence under high memory pressure, state updates are lost silently.

The CDC Architecture: Combining Debezium, Postgres WAL, and Redis Streams

Instead of making active HTTP calls outwards to check status, or relying on fragile ephemeral retry queues, high-throughput payment engines treat the primary database write-ahead log (WAL) as the immutable single source of truth.

By leveraging PostgreSQL Logical Decoding Docs, every insert or state update to an raw inbound webhook staging table is recorded in the PostgreSQL WAL. Tools like Debezium Documentation stream these raw WAL row modifications directly into Redis Streams or Apache Kafka without touching application runtime memory or executing complex SQL queries.

+-----------------------+     1. Raw POST     +------------------------+
|  Sponsor Bank Engine  | ------------------> |  Ingress Webhook API   |
+-----------------------+                     +------------------------+
                                                          |
                                                          | 2. Insert Raw Payload
                                                          v
+-----------------------+     4. Stream       +------------------------+
| Debezium CDC Connector| <------------------ |  Postgres WAL (pg_wal) |
+-----------------------+  (Logical Decoding) +------------------------+
            |
            | 3. Publish Event
            v
+-----------------------+     5. Consume      +------------------------+
| Redis Stream (Events) | ------------------> |  Ledger Engine Worker  |
+-----------------------+                     +------------------------+
                                                          |
                                                          | 6. Deterministic Apply
                                                          v
                                              +------------------------+
                                              | Core Double-Entry DB   |
                                              +------------------------+

The flow operates as follows:

  1. Ultra-Fast Ingress Staging: The public webhook endpoint receives the payload from the sponsor bank, validates the HMAC-SHA512 header signature, inserts the unparsed payload directly into a raw_webhook_logs PostgreSQL table, and returns an immediate HTTP 200 OK in under 15 milliseconds. No business logic or balance checks happen in this request cycle.
  2. WAL Capture: PostgreSQL appends the insertion to pg_wal.
  3. CDC Streaming: Debezium reads the WAL event using the pgoutput plugin and publishes an event to Redis Streams.
  4. State Machine Worker: A isolated worker process reads from the Redis Stream, executes a deterministic state machine transition, and updates the core ledger using optimistic locking.

Implementing an Idempotent CDC Event Consumer

Below is a TypeScript implementation of a production-ready event consumer that processes out-of-order webhook events emitted from CDC streams. It guarantees that an outdated webhook or out-of-order event can never mutate a ledger record that has already advanced to a terminal state (SUCCESS or FAILED).

typescriptimport { createClient } from 'redis';import { Pool } from 'pg';interface NIPWebhookPayload { sessionId: string; accountNumber: string; amount: number; status: 'PENDING' | 'SUCCESS' | 'FAILED' | 'REVERSED'; txRef: string; eventTimestamp: string;}const pgPool = new Pool({ connectionString: process.env.DATABASE_URL });const redisClient = createClient({ url: process.env.REDIS_URL });// Allowed state transitions mapped explicitlyconst VALID_TRANSITIONS: Record<string, string[]> = { 'INITIATED': ['PENDING', 'SUCCESS', 'FAILED'], 'PENDING': ['SUCCESS', 'FAILED'], 'SUCCESS': ['REVERSED'], // A success transaction can only move to REVERSED 'FAILED': [], // Terminal state 'REVERSED': [] // Terminal state};export async function processCDCEvent(payload: NIPWebhookPayload): Promise<void> { const client = await pgPool.connect(); try { await client.query('BEGIN'); // 1. Fetch current transaction record with optimistic version tracking const res = await client.query( `SELECT id, status, version, amount FROM virtual_account_ledger WHERE session_id = $1 FOR UPDATE`, [payload.sessionId] ); if (res.rows.length === 0) { // Handle edge case: Webhook arrived before internal initiation entry was committed await client.query( `INSERT INTO virtual_account_ledger (session_id, account_number, amount, status, version, created_at) VALUES ($1, $2, $3, $4, 1, NOW())`, [payload.sessionId, payload.accountNumber, payload.amount, payload.status] ); await client.query('COMMIT'); return; } const currentRecord = res.rows[0]; const currentStatus = currentRecord.status as string; const nextStatus = payload.status; // 2. Validate out-of-order logic if (currentStatus === nextStatus) { // Duplicate event; safely ignore await client.query('ROLLBACK'); return; } const allowedNextStates = VALID_TRANSITIONS[currentStatus] || []; if (!allowedNextStates.includes(nextStatus)) { console.warn( `[CDC_OUT_OF_ORDER_DROP] Ignored illegal transition from ${currentStatus} to ${nextStatus} for SessionID: ${payload.sessionId}` ); await client.query('ROLLBACK'); return; } // 3. Apply state transition with optimistic lock update const updateRes = await client.query( `UPDATE virtual_account_ledger SET status = $1, version = version + 1, updated_at = NOW() WHERE session_id = $2 AND version = $3`, [nextStatus, payload.sessionId, currentRecord.version] ); if (updateRes.rowCount === 0) { throw new Error(`[CONCURRENCY_CONFLICT] Version mismatch for SessionID ${payload.sessionId}`); } // 4. If transition is SUCCESS, execute wallet credit in same DB transaction if (nextStatus === 'SUCCESS') { await client.query( `UPDATE user_wallets SET balance = balance + $1, updated_at = NOW() WHERE account_number = $2`, [payload.amount, payload.accountNumber] ); } await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); }}

What To Do About It: Practical Migration Roadmap

If you are currently relying on cron polling or simple queue retries for bank transfer reconciliation, follow this step-by-step transition path to move toward a resilient CDC setup:

Step 1: Decouple Ingress Webhook Acceptance from Processing

Modify your public payment webhook handler immediately. It should perform basic signature check, drop the JSON payload into an unparsed PostgreSQL table (raw_inbound_webhooks), and return HTTP 200 within 20 milliseconds. Never place HTTP outbound requests or user wallet logic inside the inbound HTTP webhook controller.

Step 2: Enable PostgreSQL Logical Replication

In your database configuration (postgresql.conf or RDS Parameter Group), set wal_level = logical. Increase max_replication_slots and max_wal_senders to accommodate CDC connectors.

Step 3: Enforce Strict State Machine Rules in Ledger Updates

Ensure your core database schema contains a integer version column and explicit state transition enforcement, as shown in the code snippet above. Dropping unexpected out-of-order events at the database level eliminates ghost double-credits completely.

Step 4: Relegate Core Bank Polling to a Dead-Letter Fallback Only

Do not abandon bank status endpoints entirely—relegate them. Polling should only run for transactions that have remained in a PENDING state for longer than 6 hours without receiving any webhook or CDC event. Limit polling to a strict, rate-limited worker that runs once every 30 minutes, querying only aged records.

By moving from active HTTP polling to passive CDC logical decoding, your infrastructure stops hammering core bank gateways, eliminates web-tier database lock contention, and maintains transactional integrity even when NIBSS webhooks arrive hours late and out of order.

Frequently Asked Questions

How does CDC handle database schema migrations without breaking event workers?

When using Debezium with PostgreSQL logical decoding, schema changes (like ALTER TABLE ADD COLUMN) are automatically captured in the event metadata stream. To avoid worker crashes, always make schema changes backward-compatible (e.g., adding nullable columns or default values) and update your CDC event parsing layer before running table migrations.

Is Redis Streams reliable enough for streaming payment events compared to Apache Kafka?

For most Nigerian startups and mid-market fintechs processing under 5,000 transactions per minute, Redis Streams configured with AOF (Append-Only File) persistence set to everysec offers the ideal balance of sub-millisecond latency and operational simplicity. Apache Kafka is only necessary when you require multi-region replication or retaining event streams for weeks.

What happens if the sponsor bank never sends a webhook or status response?

For permanent drop-offs where NIBSS settles internally but no webhook is ever generated, your aged fallback reconciler (Step 4) handles final resolution. Because it runs on aged transactions (>6 hours old) in a trickle queue, it avoids causing thundering herd problems on the core bank gateway while ensuring uncredited funds are captured during end-of-day settlement.

Neobot Engineering Standard

Every system deployed by Neobot Tech incorporates enterprise baseline practices. We continuously audit our database topologies, REST API query paths, and frontend modular bundles to prevent latency spikes and ensure top-tier security posture.

Tags:#Engineering#PostgreSQL#System Design#Fintech#Redis#CDC

Discussion

Comments Coming Soon

We are currently migrating our discussion engine to a new real-time database schema. Check back shortly to join the conversation.