Audit an inherited Paystack integration in this order: 1) Check that server-side verification happens for every payment. 2) Look for the double-grant bug (webhook and callback both granting value). 3) Verify API keys are stored as environment variables, not in code. 4) Confirm webhook signature validation exists. 5) Check amount and currency verification against database values. 6) Review the database schema for proper payment recording. 7) Test the full payment flow in test mode.
Payment code that works "well enough" in development can silently lose money in production. The previous developer might not have known about price tampering attacks, the double-grant bug, or the importance of webhook signature validation. They might have hardcoded test keys that were replaced with live keys but never properly secured. They might have no reconciliation process at all.
Payment integration bugs are expensive. Unlike a UI bug that causes visual glitches, a payment bug means you give away products for free, charge customers twice, or miss revenue entirely. And these bugs are often silent. Nobody files a support ticket to say "you gave me a free product."
This audit checklist is ordered from most critical (money-losing bugs) to less critical (operational improvements). Work through it in order. Fix the critical items immediately. Schedule the rest for the coming weeks.
Search the codebase for calls to api.paystack.co/transaction/verify. If you find none, the integration is not verifying transactions server-side. This is the most critical bug. Anyone can fake a payment by triggering the frontend callback.
// What to search for in the codebase
// Good: server-side verification exists
// Look for: 'transaction/verify' in server code (Node.js, Python, PHP, etc.)
// Red flags:
// 1. No verify call anywhere
// 2. Verify call is in frontend code (React, Vue, Angular)
// - Search for: 'transaction/verify' in files under /src, /client, /public
// - This exposes the secret key in the browser
// 3. Verify call exists but the result is not checked properly
// - Only checks top-level status (boolean), not data.status (string)
// - Does not check amount
// - Does not check currency
If verification is missing, implement it immediately using the pattern in the verify transaction guide. This is a stop-the-bleeding fix. Deploy it today.
If verification exists, check what it actually verifies. Search for where the response is handled. Does it check data.status === "success"? Does it compare data.amount against the expected amount from the database? Does it check data.currency? All three are required.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Search for all code paths that grant value (update order status to "paid", add credits, send confirmation emails). Count them. If you find more than one code path that grants value for the same transaction reference, you have the double-grant bug.
Common locations to check:
// Audit: search for all places that mark an order as paid
// grep -r "status.*paid" --include="*.js" --include="*.ts"
// grep -r "status.*completed" --include="*.js" --include="*.ts"
// grep -r "grantAccess|addCredits|markPaid" --include="*.js" --include="*.ts"
// Check if there is an idempotency mechanism
// grep -r "FOR UPDATE|findOneAndUpdate|already_paid" --include="*.js" --include="*.ts"
If multiple code paths grant value without an idempotency lock, fix it using the pattern in the double-grant bug guide. The quickest fix is to make the redirect callback display-only (show success/pending page but never grant value) and let only the webhook handler grant value.
Search the codebase for Paystack keys:
// Search for hardcoded keys
// grep -r "sk_live_|sk_test_|pk_live_|pk_test_" --include="*.js" --include="*.ts" --include="*.env*" --include="*.json"
// Red flags:
// 1. Secret key (sk_*) in any frontend file
// 2. Secret key hardcoded in source code (not from env variable)
// 3. Secret key in a config file that is committed to git
// 4. Test keys (sk_test_) being used in production
// 5. .env file committed to git (check git history too)
Verify that:
process.env.PAYSTACK_SECRET_KEY.env file is in .gitignoregit log -p --all -S "sk_live")If the secret key was ever committed to git, rotate it immediately in the Paystack dashboard. Even if the commit was later removed, anyone with access to the repository history can find it.
Search for HMAC or signature validation in the webhook handler:
// Search for signature validation
// grep -r "createHmac|hmac|x-paystack-signature|webhook.*secret" --include="*.js" --include="*.ts"
// Correct webhook signature validation
var crypto = require('crypto');
function validateWebhookSignature(body, signature, secret) {
var hash = crypto.createHmac('sha512', secret)
.update(JSON.stringify(body))
.digest('hex');
return hash === signature;
}
// In the webhook handler
app.post('/webhooks/paystack', function(req, res) {
var signature = req.headers['x-paystack-signature'];
if (!validateWebhookSignature(req.body, signature, process.env.PAYSTACK_SECRET_KEY)) {
console.error('Invalid webhook signature');
return res.sendStatus(401);
}
// Process webhook...
});
If signature validation is missing, anyone who knows your webhook URL can send fake webhook events. They could fake a charge.success event and your system would grant value without any actual payment. Add signature validation immediately.
Examine the database schema for payment-related tables. Look for:
-- Audit queries to check the database
-- Check for duplicate payment references
SELECT paystack_reference, COUNT(*)
FROM orders
GROUP BY paystack_reference
HAVING COUNT(*) > 1;
-- Check for orders stuck in pending status for too long
SELECT COUNT(*), MIN(created_at) as oldest
FROM orders
WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '7 days';
-- Check for orders marked as paid without a paystack_reference
SELECT COUNT(*)
FROM orders
WHERE status = 'paid'
AND (paystack_reference IS NULL OR paystack_reference = '');
Duplicate references indicate the double-grant bug or a missing unique constraint. Old pending orders indicate lost webhooks or a broken verification flow. Paid orders without references indicate value was granted without proper verification.
Review how the integration handles errors. Common failure points:
// Check each error scenario:
// 1. What happens when the Paystack API is unreachable?
// Does the code retry? Does it queue for later? Or does it crash?
// 2. What happens when verification returns a non-success status?
// Does the code properly reject, or does it grant value anyway?
// 3. What happens when the webhook handler throws an error?
// Does it return a non-200 status so Paystack retries?
// Or does it return 200 even on error, losing the webhook forever?
// 4. What happens when the database is down during payment processing?
// Does the customer get a confusing error? Is the transaction lost?
// Look for empty catch blocks (the worst pattern)
// try { ... } catch (e) { } // Error is silently swallowed
Empty catch blocks are the most dangerous pattern. An error during verification is silently swallowed, and the code continues as if verification succeeded. Replace every empty catch block with proper error handling and logging.
Switch to Paystack test mode (sk_test_ keys) and run through the entire payment flow:
Document each test result. This becomes your audit evidence and guides the fix priority.
Organize your findings by severity:
Critical (fix today):
High (fix this week):
Medium (fix this month):
Low (improvement):
For the full set of patterns you should implement, see the complete verification and reconciliation guide. If your team needs hands-on training to implement these patterns correctly, the McTaba 26-week bootcamp covers production payment integration from scratch.
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