DEV Community

zikarelhub
zikarelhub

Posted on

Nigerian Fintech Payment Settlement — Webhooks, Idempotency and Reconciliation

A Paystack success message confirms bank authorization — not settlement. Settlement is T+1 or T+2. The gap between these two states is where Nigerian fintech financial exposure lives. Here is the complete implementation.

The Problem

// DANGEROUS: crediting wallet on success message
app.post('/webhook/paystack', async (req, res) => {
  const event = req.body;
  if (event.event === 'charge.success') {
    // This is AUTHORIZATION — not settlement
    // If settlement later fails — money is gone
    await creditUserWallet(event.data.metadata.userId, event.data.amount / 100);
  }
  res.sendStatus(200);
});
Enter fullscreen mode Exit fullscreen mode

The Correct Implementation — Three Layers

// Layer 1: Signature verification
app.post('/webhook/paystack',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // Verify signature FIRST — before anything else
    const hash = crypto
      .createHmac('sha512', process.env.PAYSTACK_SECRET)
      .update(req.body) // RAW body — critical
      .digest('hex');

    if (hash !== req.headers['x-paystack-signature']) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body);
    const { reference } = event.data;

    // Layer 2: Idempotency — safe for duplicate events
    const alreadyProcessed = await WebhookEvent.findOne({
      where: { reference, status: 'PROCESSED' }
    });
    if (alreadyProcessed) return res.status(200).json({ status: 'duplicate' });

    // Layer 3: Independent verification with Paystack API
    const verified = await fetch(
      `https://api.paystack.co/transaction/verify/${reference}`,
      { headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET}` } }
    ).then(r => r.json());

    if (verified.data.status !== 'success') {
      return res.status(200).json({ status: 'not_successful' });
    }

    // Update to AUTHORIZED — not SETTLED yet
    await Transaction.update(
      { status: 'authorized', authorizedAt: new Date() },
      { where: { paystackReference: reference } }
    );

    // Schedule settlement check for T+1
    await settlementQueue.add(
      { reference },
      { delay: 24 * 60 * 60 * 1000 }
    );

    await WebhookEvent.create({ reference, status: 'PROCESSED' });
    res.status(200).json({ status: 'processed' });
  }
);
Enter fullscreen mode Exit fullscreen mode

Credit Wallet Only After Settlement Confirmed

// Settlement check — runs T+1
async function confirmSettlement(reference) {
  const settlementReport = await fetchPaystackSettlementReport(today);
  const settled = settlementReport.transactions.find(
    t => t.reference === reference
  );

  if (settled) {
    await Transaction.update(
      { status: 'settled', settledAt: new Date() },
      { where: { paystackReference: reference } }
    );

    // NOW safe to credit wallet
    await creditUserWallet(userId, settled.amount / 100);

    await Transaction.update(
      { status: 'available' },
      { where: { paystackReference: reference } }
    );
  } else {
    // Not settled — escalate if beyond T+2
    await handleSettlementMiss(reference);
  }
}
Enter fullscreen mode Exit fullscreen mode

Daily Reconciliation

async function dailyReconciliation(date) {
  const [paystackSettled, internalAuthorized] = await Promise.all([
    fetchPaystackSettlementReport(date),
    Transaction.findAll({ where: { status: 'authorized', date } })
  ]);

  // Missed webhooks — settled at Paystack but not in our system
  const missedWebhooks = paystackSettled.filter(
    s => !internalAuthorized.find(i => i.paystackReference === s.reference)
  );

  // Failed settlements — authorized in our system, not in Paystack report
  const failedSettlements = internalAuthorized.filter(
    i => !paystackSettled.find(s => s.reference === i.paystackReference)
  );

  if (missedWebhooks.length || failedSettlements.length) {
    await alertFinanceTeam({ missedWebhooks, failedSettlements, date });
  }
}
Enter fullscreen mode Exit fullscreen mode

The Rule

Success message = authorization (Step 2 of 5)
Settlement = T+1 or T+2 (Step 4 of 5)
Credit wallet only at Step 4 — never Step 2
Reconcile daily — catch every gap
Enter fullscreen mode Exit fullscreen mode

ZikarelHub LTD is Nigeria's #1 software and digital agency — Nigerian fintech payment systems built for reliability and compliance.

What payment settlement issues have you encountered building for the Nigerian market? 👇

Top comments (0)