Build your admin payments dashboard by combining data from your payments ledger (for fast queries) with real-time data from the Paystack API (for current status). Use your ledger for revenue summaries, refund totals, and fee calculations. Use the API for transaction search, customer lookup, and verifying individual payments. Cache API responses to avoid hitting rate limits.
Paystack provides a dashboard at dashboard.paystack.com. It shows transactions, settlements, and customers. It works well for quick lookups and basic monitoring. But it has limitations for day-to-day operations:
A custom dashboard combines Paystack payment data with your application context. It is built on your payments ledger for fast queries and uses the Paystack API for real-time lookups.
Your dashboard needs a handful of backend endpoints that query your ledger and the Paystack API. Here are the essential ones:
// routes/admin/payments.js
var express = require('express');
var router = express.Router();
// Middleware: require admin authentication
router.use(requireAdminAuth);
// Daily revenue summary
router.get('/api/admin/payments/summary', async function(req, res) {
var days = parseInt(req.query.days) || 7;
var result = await db.query(
'SELECT '
+ ' DATE(created_at) as date, '
+ ' currency, '
+ ' COUNT(*) FILTER (WHERE event_type = 'charge.success') as charge_count, '
+ ' COALESCE(SUM(gross_amount) FILTER (WHERE event_type = 'charge.success'), 0) as gross_revenue, '
+ ' COALESCE(SUM(fee_amount) FILTER (WHERE event_type = 'charge.success'), 0) as total_fees, '
+ ' COALESCE(SUM(net_amount) FILTER (WHERE event_type = 'charge.success'), 0) as net_revenue, '
+ ' COUNT(*) FILTER (WHERE event_type = 'refund.processed') as refund_count, '
+ ' COALESCE(SUM(gross_amount) FILTER (WHERE event_type = 'refund.processed'), 0) as refund_total '
+ 'FROM payment_ledger '
+ 'WHERE created_at >= NOW() - INTERVAL '' + days + ' days' '
+ 'GROUP BY DATE(created_at), currency '
+ 'ORDER BY date DESC'
);
return res.json({ summary: result.rows });
});
// Transaction list with filters
router.get('/api/admin/payments/transactions', async function(req, res) {
var page = parseInt(req.query.page) || 1;
var perPage = Math.min(parseInt(req.query.perPage) || 50, 100);
var offset = (page - 1) * perPage;
var conditions = ['1=1'];
var params = [];
var paramIndex = 1;
if (req.query.status) {
conditions.push('o.status = $' + paramIndex);
params.push(req.query.status);
paramIndex++;
}
if (req.query.from) {
conditions.push('l.created_at >= $' + paramIndex);
params.push(req.query.from);
paramIndex++;
}
if (req.query.to) {
conditions.push('l.created_at <= $' + paramIndex);
params.push(req.query.to);
paramIndex++;
}
if (req.query.email) {
conditions.push('l.customer_email ILIKE $' + paramIndex);
params.push('%' + req.query.email + '%');
paramIndex++;
}
params.push(perPage, offset);
var result = await db.query(
'SELECT l.reference, l.event_type, l.gross_amount, l.fee_amount, '
+ 'l.net_amount, l.currency, l.channel, l.customer_email, '
+ 'l.created_at, l.settled_at, o.status as order_status, o.id as order_id '
+ 'FROM payment_ledger l '
+ 'LEFT JOIN orders o ON o.id = l.order_id '
+ 'WHERE ' + conditions.join(' AND ') + ' '
+ 'ORDER BY l.created_at DESC '
+ 'LIMIT $' + paramIndex + ' OFFSET $' + (paramIndex + 1),
params
);
return res.json({
transactions: result.rows,
page: page,
perPage: perPage,
});
});
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Sometimes your team needs to check a specific transaction's current status on Paystack, not just what your ledger recorded. This endpoint queries the Paystack API directly:
// Look up a transaction on Paystack in real time
router.get('/api/admin/payments/lookup/:reference', async function(req, res) {
var reference = req.params.reference;
// First check our ledger
var ledgerResult = await db.query(
'SELECT * FROM payment_ledger WHERE reference = $1 ORDER BY created_at',
[reference]
);
// Then check Paystack
var paystackResponse = await fetch(
'https://api.paystack.co/transaction/verify/'
+ encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var paystackData = await paystackResponse.json();
// Check for discrepancies
var discrepancy = null;
if (ledgerResult.rows.length > 0 && paystackData.data) {
var ledgerEntry = ledgerResult.rows[0];
if (ledgerEntry.gross_amount !== paystackData.data.amount) {
discrepancy = {
type: 'amount_mismatch',
ledger: ledgerEntry.gross_amount,
paystack: paystackData.data.amount,
};
}
} else if (ledgerResult.rows.length === 0 && paystackData.data
&& paystackData.data.status === 'success') {
discrepancy = {
type: 'missing_from_ledger',
paystack_amount: paystackData.data.amount,
};
}
return res.json({
ledger: ledgerResult.rows,
paystack: paystackData.data || null,
discrepancy: discrepancy,
});
});
The discrepancy detection is useful for support tickets. When a customer says "I paid but did not get my product," your support agent can look up the reference and immediately see whether the payment exists in Paystack, whether it was recorded in your ledger, and whether there is a mismatch.
One of the most valuable dashboard panels shows orders that are stuck in a pending or processing state. These represent potential lost revenue or support issues.
// Pending transactions monitor
router.get('/api/admin/payments/pending', async function(req, res) {
var result = await db.query(
'SELECT o.id, o.paystack_reference, o.expected_amount, '
+ 'o.expected_currency, o.status, o.payment_channel, '
+ 'o.created_at, o.processing_started_at, '
+ 'EXTRACT(EPOCH FROM (NOW() - o.created_at)) / 60 as age_minutes '
+ 'FROM orders o '
+ 'WHERE o.status IN ($1, $2) '
+ 'ORDER BY o.created_at ASC',
['pending', 'processing']
);
// Group by urgency
var urgent = [];
var normal = [];
for (var i = 0; i < result.rows.length; i++) {
var row = result.rows[i];
if (row.age_minutes > 60) {
urgent.push(row);
} else {
normal.push(row);
}
}
return res.json({
total: result.rows.length,
urgent: urgent,
normal: normal,
});
});
If the urgent list grows beyond a few entries, it is a red flag. Either your webhook endpoint is down, a specific payment channel is having issues, or there is a bug in your payment processing code. This panel should be the first thing your operations team checks every morning.
For strategies on handling pending transactions, see the pending transactions guide.
Show the results of your daily reconciliation job in the dashboard. This gives your team visibility into whether the books are clean.
// Reconciliation status
router.get('/api/admin/payments/reconciliation', async function(req, res) {
var days = parseInt(req.query.days) || 14;
var runs = await db.query(
'SELECT run_date, settlement_date, matched_count, missing_count, '
+ 'mismatch_count, orphan_count, status '
+ 'FROM reconciliation_runs '
+ 'WHERE run_date >= NOW() - INTERVAL '' + days + ' days' '
+ 'ORDER BY run_date DESC'
);
var openIssues = await db.query(
'SELECT ri.issue_type, ri.reference, ri.details, ri.created_at, '
+ 'rr.settlement_date '
+ 'FROM reconciliation_issues ri '
+ 'JOIN reconciliation_runs rr ON rr.id = ri.run_id '
+ 'WHERE ri.resolved = false '
+ 'ORDER BY ri.created_at DESC'
);
return res.json({
runs: runs.rows,
open_issues: openIssues.rows,
last_clean_run: runs.rows.find(function(r) { return r.status === 'clean'; }),
});
});
Highlight the last clean run date. If the last clean reconciliation was more than 3 days ago, the dashboard should show a warning. If it was more than 7 days ago, show an alert. Stale reconciliation means nobody is watching the books.
Dashboard pages get refreshed frequently. If every page load hits the Paystack API, you will burn through rate limits. Cache API responses for a short period:
// Simple in-memory cache
var cache = {};
async function cachedFetch(key, ttlSeconds, fetchFn) {
var now = Date.now();
var cached = cache[key];
if (cached && (now - cached.timestamp) < ttlSeconds * 1000) {
return cached.data;
}
var data = await fetchFn();
cache[key] = { data: data, timestamp: now };
return data;
}
// Usage in a dashboard endpoint
router.get('/api/admin/payments/paystack-balance', async function(req, res) {
var balance = await cachedFetch('paystack_balance', 300, async function() {
var response = await fetch('https://api.paystack.co/balance', {
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
});
var data = await response.json();
return data.data;
});
return res.json({ balance: balance });
});
Cache times depend on the data type. Balance and summary data can be cached for 5 minutes. Individual transaction lookups should not be cached (or cached for only 30 seconds) because support agents need current data. Settlement data can be cached for longer since it changes infrequently.
Understanding which payment channels your customers use helps you optimize your checkout flow and debug channel-specific issues:
// Payment channel breakdown
router.get('/api/admin/payments/channels', async function(req, res) {
var days = parseInt(req.query.days) || 30;
var result = await db.query(
'SELECT '
+ ' channel, '
+ ' currency, '
+ ' COUNT(*) as transaction_count, '
+ ' SUM(gross_amount) as total_volume, '
+ ' SUM(fee_amount) as total_fees, '
+ ' ROUND(AVG(gross_amount)) as avg_transaction '
+ 'FROM payment_ledger '
+ 'WHERE event_type = $1 '
+ ' AND created_at >= NOW() - INTERVAL '' + days + ' days' '
+ 'GROUP BY channel, currency '
+ 'ORDER BY total_volume DESC',
['charge.success']
);
return res.json({ channels: result.rows });
});
If bank transfer volume is growing, you might want to optimize the bank transfer UX on your checkout page. If USSD volume is low, maybe you should remove it to simplify the checkout. If card failure rates are high (visible by comparing initialized transactions to successful ones), investigate whether specific card types or banks are causing issues.
Payment data is sensitive. Your admin dashboard must be protected:
// Admin authentication middleware
async function requireAdminAuth(req, res, next) {
var token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
var user = await verifyAdminToken(token);
if (!user || !user.is_admin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Log dashboard access for audit
console.log(JSON.stringify({
event: 'admin_dashboard_access',
user_id: user.id,
email: user.email,
path: req.path,
timestamp: new Date().toISOString(),
}));
req.adminUser = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
Additional security measures:
If you want to learn how to build secure admin systems like this from the ground up, the McTaba 26-week bootcamp covers full-stack engineering including authentication, authorization, and production security patterns.
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