Building an Admin Payments Dashboard on the Paystack API
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.
Why Build Your Own Dashboard
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:
- It does not show your application data. You cannot see which orders correspond to which payments, which users made which transactions, or which products generated the most revenue.
- It does not show your internal statuses. A transaction might be "success" in Paystack but "pending fulfillment" in your system because your webhook handler crashed.
- It does not aggregate the way your business needs. You might want revenue by product, by cohort, by referral source. Paystack only knows about the payment, not your business context.
- Access control is limited. You might want your customer support team to see transaction details but not settlement data. Paystack dashboard roles may not match your internal roles.
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.
Backend API Endpoints for the Dashboard
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,
});
});
Real-Time Transaction Lookup
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.
The Pending Transactions Monitor
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.
Reconciliation Status Panel
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.
Caching Paystack API Responses
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.
Payment Channel Breakdown
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.
Securing the Dashboard
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:
- Audit logging: Log every dashboard access with the user, the endpoint, and the timestamp. If payment data leaks, the audit log helps you trace what was accessed and by whom.
- Role-based access: Not every admin needs to see everything. Support agents need transaction lookup but not settlement data. Finance needs settlement data but not customer details.
- Rate limiting: Limit how many requests each admin can make per minute. This prevents both abuse and accidental infinite loops in dashboard code.
- No Paystack secret key in the frontend. All Paystack API calls must go through your backend. The frontend sends requests to your admin API, which queries Paystack on behalf of the user.
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.
Key Takeaways
- ✓Build your dashboard on top of your payments ledger for fast aggregation queries. Use the Paystack API for real-time lookups and transaction details.
- ✓Essential dashboard views: daily revenue summary, transaction list with filters, refund tracker, pending transaction monitor, and reconciliation status.
- ✓Cache Paystack API responses for 1-5 minutes to reduce API calls. Most dashboard data does not need real-time updates.
- ✓Include a transaction search that queries both your ledger and the Paystack API. Your ledger covers recorded transactions; the API covers anything you might have missed.
- ✓Add a pending transactions panel that shows orders stuck in processing state. This is your early warning system for webhook failures.
- ✓Protect the dashboard behind authentication. Payment data is sensitive. Only authorized team members should have access.
Frequently Asked Questions
- Should I use the Paystack API or my own database for dashboard data?
- Use your database (payments ledger) for aggregation and historical queries. Use the Paystack API for real-time lookups and verifying individual transactions. Your database is faster for dashboards because it does not have API rate limits or network latency.
- Can I embed the Paystack dashboard instead of building my own?
- Paystack does not offer an embeddable dashboard widget. You need to build your own. The advantage is that your dashboard can combine payment data with your application data (orders, users, products) for a much richer view.
- How do I handle multiple currencies in the dashboard?
- Always display amounts with their currency code. Never sum amounts across different currencies. Show separate totals per currency. If you need a single "total revenue" number, convert to a base currency using a consistent exchange rate and label it clearly as a converted figure.
- What frontend framework should I use for the dashboard?
- Any framework works. React, Vue, plain HTML with JavaScript, even server-rendered pages. The backend API endpoints described in this guide return JSON. Any frontend can consume them. Choose whatever your team is comfortable with.
- How often should dashboard data refresh?
- The summary panel can refresh every 5 minutes. The pending transactions panel should refresh every 1-2 minutes since it is an early warning system. Individual transaction lookups should be real-time (no cache). Reconciliation status can refresh every 15 minutes.
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