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);
});
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' });
}
);
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);
}
}
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 });
}
}
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
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)