After every Paystack payment, call GET /transaction/verify/:reference from your server. Compare the returned amount and currency against what you expected. Only grant value after this check passes. Never trust the client. Reconcile daily by comparing your ledger against settlement reports and flagging mismatches.
A customer pays on your checkout page. The popup closes. Your frontend JavaScript fires a success callback. You grant the user access to their course, or mark their order as paid.
That flow has a fatal flaw. The frontend callback can be faked. Someone can open browser DevTools, call your success handler manually, and get value without paying. This is not a theoretical attack. It happens to real African startups every month.
Server-side verification closes this hole. After every payment, your server calls Paystack directly to confirm: did this transaction actually succeed, for the right amount, in the right currency? Only then do you grant value.
This guide covers the full verification and reconciliation pipeline. From the single API call that confirms a payment, through ledger design, to automated sweeper jobs that catch missing transactions days later. If you are building on Paystack in production, every section here applies to you.
For the full picture of Paystack integration patterns, see the complete Paystack engineering guide.
The verification endpoint is simple. One GET request, one response. Here is the call:
// Node.js - Verify a Paystack transaction
async function verifyTransaction(reference) {
const response = await fetch(
`https://api.paystack.co/transaction/verify/${encodeURIComponent(reference)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await response.json();
return data;
}
// Usage
const result = await verifyTransaction('order_abc123_1689012345');
console.log(result.data.status); // "success", "failed", or "abandoned"
console.log(result.data.amount); // Amount in kobo/pesewas/cents
console.log(result.data.currency); // "NGN", "GHS", "KES", "ZAR", "USD"
Key details about this endpoint:
The response body includes a data object with these fields that matter for verification:
{
"status": true,
"message": "Verification successful",
"data": {
"id": 1234567890,
"status": "success",
"reference": "order_abc123_1689012345",
"amount": 500000,
"currency": "NGN",
"channel": "card",
"paid_at": "2026-07-20T10:30:00.000Z",
"fees": 7500,
"customer": {
"email": "customer@example.com"
},
"metadata": {
"order_id": "abc123",
"plan": "premium"
}
}
}
Two things to watch. First, the top-level status (boolean) tells you whether the API call itself worked. The nested data.status (string) tells you whether the payment succeeded. Do not confuse them. Second, the fees field shows what Paystack charged. You will need this for your ledger.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Verifying that a transaction succeeded is not enough. You must also verify that the customer paid the right amount in the right currency. Here is why.
Suppose your checkout page sells a course for NGN 50,000. An attacker intercepts the initialization request and changes the amount to NGN 100 (or 10000 kobo). Paystack will happily process a NGN 100 payment. The transaction will show status "success". If you only check the status, you just gave away a course for NGN 100.
The fix is a three-part check on your server:
// Express.js - Complete verification handler
app.get('/api/verify-payment', async (req, res) => {
const { reference } = req.query;
if (!reference) {
return res.status(400).json({ error: 'Missing reference' });
}
// Step 1: Look up what we expected for this reference
const order = await db.orders.findOne({ paystack_reference: reference });
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
if (order.status === 'paid') {
// Already granted value for this order
return res.json({ status: 'already_paid', order_id: order.id });
}
// Step 2: Call Paystack to verify
const verification = await verifyTransaction(reference);
if (!verification.status) {
return res.status(502).json({ error: 'Paystack API error' });
}
const txn = verification.data;
// Step 3: Check status, amount, AND currency
if (txn.status !== 'success') {
return res.json({
status: 'not_paid',
paystack_status: txn.status,
});
}
if (txn.amount !== order.expected_amount_kobo) {
console.error(
`Amount mismatch for ${reference}: expected ${order.expected_amount_kobo}, got ${txn.amount}`
);
return res.status(400).json({ error: 'Amount mismatch' });
}
if (txn.currency !== order.expected_currency) {
console.error(
`Currency mismatch for ${reference}: expected ${order.expected_currency}, got ${txn.currency}`
);
return res.status(400).json({ error: 'Currency mismatch' });
}
// All checks passed - grant value
await db.orders.updateOne(
{ id: order.id },
{
status: 'paid',
paid_at: txn.paid_at,
paystack_fees: txn.fees,
paystack_id: txn.id,
channel: txn.channel,
}
);
return res.json({ status: 'paid', order_id: order.id });
});
The expected amount and currency must come from your database, not from the client request. Your server set these values when it created the order. The client should only send the reference.
Store amounts in the smallest currency unit in your database. If you store NGN 50,000 as the integer 5000000 (kobo), the comparison against Paystack's response is a simple integer equality check. No floating-point issues.
This is the most common payment bug in Paystack integrations. It works like this:
charge.success webhook to your server.This bug is sneaky because each handler works correctly in isolation. The problem is that both run for the same transaction, and neither knows the other already granted value.
The fix is an idempotency lock. Before granting value, check if value was already granted for this reference. Use an atomic database operation to prevent race conditions:
// PostgreSQL-based idempotency lock
async function grantValueOnce(reference, grantFn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// SELECT ... FOR UPDATE locks the row until the transaction completes.
// Any concurrent request for the same reference will block here.
const result = await client.query(
`SELECT id, status FROM orders
WHERE paystack_reference = $1
FOR UPDATE`,
[reference]
);
if (result.rows.length === 0) {
await client.query('ROLLBACK');
return { granted: false, reason: 'order_not_found' };
}
const order = result.rows[0];
if (order.status === 'paid') {
// Another handler already granted value
await client.query('ROLLBACK');
return { granted: false, reason: 'already_paid' };
}
// Grant value (update order, add credits, etc.)
await grantFn(client, order.id);
// Mark as paid
await client.query(
`UPDATE orders SET status = 'paid', paid_at = NOW()
WHERE id = $1`,
[order.id]
);
await client.query('COMMIT');
return { granted: true };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
Use this same function in both your webhook handler and your redirect callback handler. The first one to run will acquire the lock, grant value, and set the status to "paid". The second one will see status "paid" and skip.
An alternative approach: only grant value in the webhook handler. Have the redirect callback check the order status and display a success or pending page, but never grant value. This is simpler and works well for most applications.
// Redirect callback - display only, never grant
app.get('/payment/callback', async (req, res) => {
const { reference } = req.query;
const order = await db.orders.findOne({ paystack_reference: reference });
if (!order) {
return res.redirect('/payment/error');
}
if (order.status === 'paid') {
return res.redirect(`/payment/success?order=${order.id}`);
}
// Webhook has not fired yet. Show a "processing" page.
// The webhook will grant value when it arrives.
return res.redirect(`/payment/processing?order=${order.id}`);
});
A payments ledger is a table (or set of tables) that records every financial event in your system. It is your source of truth for "how much money moved, when, and why." Without one, reconciliation is guesswork.
Here is a minimal ledger schema for a Paystack integration:
CREATE TABLE payment_ledger (
id BIGSERIAL PRIMARY KEY,
reference VARCHAR(100) NOT NULL,
event_type VARCHAR(50) NOT NULL,
-- e.g. 'charge.success', 'refund.processed', 'transfer.success'
gross_amount BIGINT NOT NULL,
-- Amount the customer paid, in smallest currency unit
paystack_fees BIGINT NOT NULL DEFAULT 0,
-- Fees Paystack deducted
net_amount BIGINT NOT NULL,
-- gross_amount - paystack_fees (what you receive)
currency VARCHAR(3) NOT NULL,
channel VARCHAR(20),
-- 'card', 'bank', 'ussd', 'mobile_money', 'bank_transfer'
paystack_id BIGINT,
-- Paystack's internal transaction ID
customer_email VARCHAR(255),
order_id BIGINT REFERENCES orders(id),
metadata JSONB DEFAULT '{}',
-- Store the full Paystack response for audit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
settled_at TIMESTAMPTZ,
-- When Paystack settled this to your bank
UNIQUE(reference, event_type)
-- Prevents duplicate ledger entries for the same event
);
CREATE INDEX idx_ledger_reference ON payment_ledger(reference);
CREATE INDEX idx_ledger_created ON payment_ledger(created_at);
CREATE INDEX idx_ledger_settled ON payment_ledger(settled_at);
CREATE INDEX idx_ledger_order ON payment_ledger(order_id);
Key design decisions:
Write to this ledger from your webhook handler, right after verification succeeds:
// Insert a ledger entry after successful verification
async function recordPayment(txn, orderId) {
await db.query(
`INSERT INTO payment_ledger
(reference, event_type, gross_amount, paystack_fees,
net_amount, currency, channel, paystack_id,
customer_email, order_id, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (reference, event_type) DO NOTHING`,
[
txn.reference,
'charge.success',
txn.amount,
txn.fees,
txn.amount - txn.fees,
txn.currency,
txn.channel,
txn.id,
txn.customer.email,
orderId,
JSON.stringify(txn),
]
);
}
The ON CONFLICT DO NOTHING clause makes this insert idempotent. If the webhook fires twice (which Paystack can do), the second insert is silently skipped.
Paystack collects payments throughout the day and settles them to your bank account in batches. The settlement timeline depends on your country and business type. For Nigerian businesses, settlement is typically the next business day. For Kenyan businesses on M-Pesa, settlement timelines vary. Check [TODO: verify current figure on paystack.com/pricing] for the latest settlement windows.
A settlement groups multiple transactions together. You receive one deposit in your bank, but that deposit represents many individual payments. Your job is to match each settlement against your ledger entries.
Paystack provides two ways to get settlement data:
GET /settlement returns a paginated list of settlements with their amounts and dates.paymentrequest.success event fires when a settlement is processed.Here is how to fetch and process settlements:
// Fetch recent settlements from Paystack
async function fetchSettlements(fromDate, toDate) {
const params = new URLSearchParams({
from: fromDate, // ISO date string: '2026-07-01'
to: toDate,
perPage: '50',
});
const response = await fetch(
`https://api.paystack.co/settlement?${params}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await response.json();
return data.data; // Array of settlement objects
}
// Fetch transactions within a specific settlement
async function fetchSettlementTransactions(settlementId) {
const response = await fetch(
`https://api.paystack.co/settlement/${settlementId}/transactions`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await response.json();
return data.data;
}
Each settlement includes a total amount and an ID. You can then fetch the individual transactions that make up that settlement. Cross-reference each transaction's reference against your ledger.
The reconciliation check has three outcomes:
settled_at column.Settlement reconciliation confirms that Paystack's records match yours. But you also need to confirm that money actually arrived in your bank account or M-Pesa till. This is the final layer of reconciliation.
For bank accounts, download your bank statement (CSV or PDF) and compare:
// Parse bank statement CSV and reconcile against settlements
const fs = require('fs');
const csv = require('csv-parse/sync');
function reconcileBankStatement(statementPath, settlements) {
const fileContent = fs.readFileSync(statementPath, 'utf-8');
const records = csv.parse(fileContent, {
columns: true,
skip_empty_lines: true,
});
const bankDeposits = records
.filter(row => parseFloat(row.Credit) > 0)
.map(row => ({
date: new Date(row.Date),
amount: Math.round(parseFloat(row.Credit) * 100), // Convert to kobo
description: row.Description,
}));
const results = {
matched: [],
unmatched_bank: [],
unmatched_settlement: [],
};
const settlementsCopy = [...settlements];
for (const deposit of bankDeposits) {
// Look for a settlement with matching amount and approximate date
const matchIndex = settlementsCopy.findIndex(
s =>
s.total_amount === deposit.amount &&
Math.abs(new Date(s.settled_date) - deposit.date) < 2 * 24 * 60 * 60 * 1000
// Allow 2-day tolerance for bank processing
);
if (matchIndex >= 0) {
results.matched.push({
bank: deposit,
settlement: settlementsCopy[matchIndex],
});
settlementsCopy.splice(matchIndex, 1);
} else {
results.unmatched_bank.push(deposit);
}
}
results.unmatched_settlement = settlementsCopy;
return results;
}
Common reasons for mismatches:
For M-Pesa-based settlements in Kenya, you receive funds through Paybill or Till. Download the M-Pesa statement from the Safaricom portal or via the Daraja API (if you have access to the statement endpoint). The matching logic is similar, but the description field will contain the Paybill number and account reference.
Automate this process. Manual reconciliation works when you process 10 transactions a day. At 100+ daily transactions, you need a script that runs automatically and alerts you only when there are mismatches.
A sweeper job is a scheduled process that finds transactions your system missed. It catches cases where:
The sweeper works by listing recent transactions from Paystack and checking each one against your ledger:
// Sweeper job - runs daily via cron
async function sweepMissingTransactions() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const fromDate = yesterday.toISOString().split('T')[0];
const toDate = new Date().toISOString().split('T')[0];
let page = 1;
let hasMore = true;
const missing = [];
const amountMismatches = [];
while (hasMore) {
const response = await fetch(
`https://api.paystack.co/transaction?from=${fromDate}&to=${toDate}&status=success&perPage=100&page=${page}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await response.json();
const transactions = data.data;
for (const txn of transactions) {
// Check if this transaction exists in our ledger
const ledgerEntry = await db.query(
`SELECT id, gross_amount, currency FROM payment_ledger
WHERE reference = $1 AND event_type = 'charge.success'`,
[txn.reference]
);
if (ledgerEntry.rows.length === 0) {
// Transaction exists in Paystack but not in our ledger
missing.push({
reference: txn.reference,
amount: txn.amount,
currency: txn.currency,
paid_at: txn.paid_at,
customer_email: txn.customer?.email,
});
} else {
// Verify amounts match
const entry = ledgerEntry.rows[0];
if (entry.gross_amount !== txn.amount) {
amountMismatches.push({
reference: txn.reference,
ledger_amount: entry.gross_amount,
paystack_amount: txn.amount,
});
}
}
}
// Check pagination
const meta = data.meta;
hasMore = page * meta.perPage < meta.total;
page++;
// Rate limiting: Paystack allows a reasonable request rate,
// but add a small delay to be safe
await new Promise(resolve => setTimeout(resolve, 200));
}
// Report findings
if (missing.length > 0) {
console.error(`SWEEPER: Found ${missing.length} missing transactions`);
await sendAlert('Missing Transactions Found', {
count: missing.length,
transactions: missing,
});
// Optionally auto-reconcile by verifying and recording each
for (const txn of missing) {
try {
const verification = await verifyTransaction(txn.reference);
if (verification.data.status === 'success') {
await recordPayment(verification.data, null);
// You may need to manually link to an order
}
} catch (err) {
console.error(`Failed to reconcile ${txn.reference}:`, err);
}
}
}
if (amountMismatches.length > 0) {
console.error(`SWEEPER: Found ${amountMismatches.length} amount mismatches`);
await sendAlert('Amount Mismatches Found', {
count: amountMismatches.length,
mismatches: amountMismatches,
});
}
return { missing: missing.length, mismatches: amountMismatches.length };
}
Schedule this job to run daily. Use cron, a queue-based scheduler, or a managed service like Railway Cron or Render Cron Jobs:
# crontab entry - run at 6 AM every day
0 6 * * * node /app/jobs/sweeper.js >> /var/log/sweeper.log 2>&1
Important sweeper design decisions:
ON CONFLICT DO NOTHING clause in your ledger insert means running the sweeper twice is safe.Here is the complete pipeline, from payment to reconciliation:
charge.success, your handler verifies the signature, calls Verify Transaction, checks amount and currency against your order, acquires the idempotency lock, grants value, and writes to the ledger.settled_at for matched entries.This pipeline has multiple safety nets. Each layer catches problems that slipped through the previous one. The webhook is your primary path. The redirect callback is a user-experience fallback. The sweeper catches lost webhooks. Settlement reconciliation catches everything else.
If you want to build production payment systems like this from scratch, the McTaba 26-week bootcamp walks you through the full stack, from frontend checkout to backend verification to production monitoring. KES 120,000 for a career-changing deep dive into software engineering.
For more on Paystack integration patterns, read the complete Paystack engineering guide.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course