Paystack Payment Verification and Reconciliation: Complete Guide
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.
Why Server-Side Verification Is Non-Negotiable
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 Verify Transaction Endpoint
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 reference is the one you passed when initializing the transaction, not the Paystack transaction ID.
- The amount is returned in the smallest currency unit. For NGN, that means kobo (divide by 100 to get naira). For KES, it is cents.
- You must use your secret key, not your public key. This call is server-only.
- The endpoint is idempotent. You can call it multiple times for the same reference and get the same result.
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.
Amount and Currency: The Server-Side Check
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.
The Double-Grant Bug
This is the most common payment bug in Paystack integrations. It works like this:
- Customer pays successfully.
- Paystack sends a
charge.successwebhook to your server. - Your webhook handler verifies the transaction and grants value (marks order as paid, adds credits, sends email).
- Meanwhile, the customer's browser redirects to your callback URL.
- Your callback handler also verifies the transaction and grants value again.
- The customer gets double credits, two confirmation emails, or two shipments.
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}`);
});
Designing a Payments Ledger
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:
- Separate gross, fees, and net. When you receive a settlement from Paystack, the amount deposited is net of fees. If your ledger only stores the gross amount, you cannot reconcile against bank statements without recalculating fees every time.
- Store the event type. A single reference can generate multiple ledger entries: a charge, a partial refund, another partial refund. The UNIQUE constraint on (reference, event_type) prevents duplicates while allowing different event types.
- JSONB metadata column. Store the full Paystack API response here. When a customer disputes a charge six months from now, you will want the original payload without calling Paystack's API (which may have retention limits).
- Use BIGINT for money. Never use FLOAT or DOUBLE for currency. BIGINT stores kobo/pesewas/cents exactly. A BIGINT can hold values up to 9.2 quintillion, which is enough for any payment.
- settled_at is nullable. Transactions are created when the payment happens but only settled when Paystack deposits funds. You update this column during reconciliation.
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.
Settlement Reconciliation
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:
- Settlements API:
GET /settlementreturns a paginated list of settlements with their amounts and dates. - Settlement webhook: The
paymentrequest.successevent 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:
- Matched: The transaction exists in both Paystack's settlement and your ledger, with matching amounts. Update the
settled_atcolumn. - In Paystack but not in your ledger: A payment succeeded on Paystack but your system never recorded it. This means a webhook was lost. Investigate immediately.
- In your ledger but not in Paystack's settlement: Your system recorded a payment that Paystack did not settle. This could mean the payment was reversed, refunded, or is still pending settlement.
Reconciling Against Bank and M-Pesa Statements
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:
- Timing: A settlement processed on Friday may not appear in your bank until Monday. Allow a 2-3 business day window.
- Paystack description format: The bank statement description may say "PAYSTACK SETTLEMENT" or include a batch ID. Map these descriptions to your settlement records.
- Rounding: Some banks truncate decimal amounts. Compare amounts with a small tolerance (1-2 units in the smallest denomination).
- Fees: Your bank may charge deposit fees that reduce the credited amount. Account for these in your reconciliation logic.
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.
Building a Sweeper Job
A sweeper job is a scheduled process that finds transactions your system missed. It catches cases where:
- A webhook was lost because your server was down or returned a non-200 response and Paystack gave up retrying.
- The customer paid, but your initialization record was not saved (database error during checkout).
- A pending transaction eventually succeeded but no event reached your system.
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:
- Time window: Sweep the previous 24-48 hours. Going further back hits rate limits and wastes API calls on transactions you have already reconciled.
- Only sweep successful transactions. Failed and abandoned transactions do not need reconciliation because no money moved.
- Alert, do not auto-fix. When the sweeper finds a missing transaction, it should alert your team. Auto-granting value days later can confuse customers and create accounting issues. Let a human decide what to do.
- Idempotent recording. The
ON CONFLICT DO NOTHINGclause in your ledger insert means running the sweeper twice is safe. - Rate limiting. Add a delay between API calls. Paystack has rate limits, and a sweeper that blasts through thousands of transactions can get throttled.
The Full Verification and Reconciliation Pipeline
Here is the complete pipeline, from payment to reconciliation:
- Payment initialization: Your server creates an order record with the expected amount, currency, and a unique reference. It calls Paystack's Initialize Transaction endpoint and sends the customer to checkout.
- Webhook handler: When Paystack sends
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. - Redirect callback: When the customer returns to your site, your callback checks the order status. If already paid (by the webhook), show success. If not yet paid, show "processing" and let the webhook handle it. Alternatively, verify here too, but use the same idempotency lock.
- Daily sweeper: A cron job lists yesterday's successful transactions from Paystack and flags any that are missing from your ledger.
- Settlement reconciliation: When Paystack settles, fetch the settlement details and match them against your ledger. Update
settled_atfor matched entries. - Bank statement reconciliation: Monthly (or weekly), compare bank deposits against Paystack settlements. Flag any amounts that do not match.
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.
Key Takeaways
- ✓Always verify transactions server-side by calling GET /transaction/verify/:reference. Never grant value based on a client callback or redirect alone.
- ✓Check both amount and currency from the verification response against your expected values. A mismatch means the customer tampered with the checkout or a different transaction reference was reused.
- ✓The double-grant bug happens when both your webhook handler and your redirect callback grant value for the same transaction. Use an idempotency lock on the transaction reference to prevent it.
- ✓Design your payments ledger with separate columns for gross amount, Paystack fees, and net amount. Record every state transition so you can reconstruct the timeline during disputes.
- ✓Run a daily sweeper job that fetches transactions from Paystack and compares them against your database. Flag any transaction that exists on one side but not the other.
Frequently Asked Questions
- How many times can I call the Verify Transaction endpoint for the same reference?
- As many times as you need. The endpoint is idempotent and returns the same result every time. There is no penalty for calling it multiple times, though you should respect Paystack's rate limits.
- What happens if my webhook handler is down when a payment succeeds?
- Paystack retries webhook delivery several times over a period of hours. If all retries fail, the webhook is lost. Your sweeper job catches these cases by listing successful transactions from Paystack and comparing them against your ledger.
- Should I verify the transaction in my webhook handler if I already verified the signature?
- Yes. Signature verification proves the webhook came from Paystack. Transaction verification proves the payment actually succeeded for the right amount. They serve different purposes. Always do both.
- How far back can I query transactions from the Paystack API?
- The Paystack List Transactions endpoint supports date filters. You can query transactions from any date in your account history. For sweeper jobs, limit your queries to the last 24-48 hours to avoid unnecessary API calls.
- Do I need a payments ledger if I only have a simple orders table?
- For a simple app with only basic purchases, an orders table with a status column may be enough. But once you handle refunds, partial payments, disputes, or multiple payment methods, a dedicated ledger becomes essential. It is worth building from the start.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon