After verifying a transaction with the Paystack API, compare the returned amount and currency against the expected values in your database. If the paid amount does not match the order total, reject the transaction and log it as a potential attack. This single check stops the most common exploit against Paystack integrations.
Here is the verification code that appears in countless Paystack integration tutorials, blog posts, and production codebases:
app.get('/payment/verify', async function(req, res) {
var reference = req.query.reference;
var response = await verifyTransaction(reference);
if (response.data.status === 'success') {
// Grant access, ship product, activate subscription
await activateOrder(reference);
return res.redirect('/thank-you');
}
res.redirect('/payment-failed');
});
This code answers one question: "Did the customer pay?" But it does not answer the critical follow-up: "Did the customer pay the right amount?"
A transaction with status "success" means money moved. It does not mean the correct amount of money moved. A payment of 1 kobo (less than a penny) is still a "successful" transaction.
The attacker visits your checkout page. Using browser DevTools, they modify the amount parameter in the Paystack Inline JS call. Instead of paying the listed price, they pay the minimum possible amount.
The transaction succeeds on the Paystack side because Paystack processed a valid payment. Your verification endpoint checks that the status is "success" and it is. Your code grants value. The attacker gets a product or service worth thousands of naira for virtually nothing.
This exploit is so simple that it has been documented in YouTube tutorials and blog posts aimed at "finding free stuff online." Any Paystack integration without amount validation is a target.
The same exploit works through different vectors:
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
The fix has three parts: store the expected amount, verify the paid amount, and compare them.
app.post('/checkout', async function(req, res) {
var productId = req.body.productId;
// Get the price from your database (not from the request)
var product = await db.query(
'SELECT price_kobo, currency FROM products WHERE id = $1',
[productId]
);
if (!product.rows.length) {
return res.status(404).json({ error: 'Product not found' });
}
var reference = 'order_' + Date.now() + '_' + Math.random().toString(36).substring(7);
// Store the expected amount with the order
await db.query(
'INSERT INTO orders (reference, product_id, expected_amount, expected_currency, status) VALUES ($1, $2, $3, $4, $5)',
[reference, productId, product.rows[0].price_kobo, product.rows[0].currency, 'pending']
);
res.json({
reference: reference,
amount: product.rows[0].price_kobo,
publicKey: process.env.PAYSTACK_PUBLIC_KEY,
});
});
app.get('/payment/verify', async function(req, res) {
var reference = req.query.reference;
// Get the expected amount from your database
var order = await db.query(
'SELECT expected_amount, expected_currency, status FROM orders WHERE reference = $1',
[reference]
);
if (!order.rows.length) {
return res.status(404).json({ error: 'Order not found' });
}
if (order.rows[0].status === 'paid') {
return res.redirect('/thank-you'); // Already processed
}
// Verify with Paystack
var response = await verifyTransaction(reference);
if (response.data.status !== 'success') {
return res.redirect('/payment-failed');
}
// THE CRITICAL CHECK
var paidAmount = response.data.amount;
var paidCurrency = response.data.currency;
var expectedAmount = order.rows[0].expected_amount;
var expectedCurrency = order.rows[0].expected_currency;
if (paidAmount !== expectedAmount || paidCurrency !== expectedCurrency) {
console.error(
'AMOUNT MISMATCH: ref=' + reference +
' paid=' + paidAmount + ' ' + paidCurrency +
' expected=' + expectedAmount + ' ' + expectedCurrency
);
await db.query(
'UPDATE orders SET status = $1 WHERE reference = $2',
['amount_mismatch', reference]
);
return res.status(400).json({ error: 'Payment amount does not match order total' });
}
// All checks passed
await db.query(
'UPDATE orders SET status = $1, paid_at = NOW() WHERE reference = $2',
['paid', reference]
);
await grantAccess(reference);
res.redirect('/thank-you');
});
Not all sources of amount information are equally trustworthy:
| Source | Trust Level | Use For |
|---|---|---|
| Your database | Highest | Expected amount (authoritative price) |
| Paystack API (/transaction/verify) | High | Paid amount (what was actually charged) |
| Webhook payload | Medium | Trigger for verification, not authoritative |
| Callback URL parameters | Low | Reference lookup only |
| Frontend request body | None | Never trust for pricing |
The pattern is: get the expected amount from your database and the paid amount from the Paystack API. Compare the two. Do not use any other source for either value.
A common mistake is trusting the webhook payload for the amount. While webhook payloads are signed and legitimate, they should still trigger an API verification call for critical decisions like amount validation. This is defense in depth.
Amount validation has several edge cases that trip up developers:
If your business model allows partial payments or installments, your validation logic needs to account for this. Do not blindly reject amounts that are less than the full price if partial payment is legitimate.
var isFullPayment = paidAmount === expectedAmount;
var isValidPartialPayment = order.rows[0].allows_partial
&& paidAmount >= order.rows[0].minimum_payment;
if (!isFullPayment && !isValidPartialPayment) {
// Reject: not a valid payment amount
}
If you accept multiple currencies, do not just compare numbers. A payment of 5000 in GHS (Ghana Cedis) is not the same as 5000 in NGN (Nigerian Naira). Always compare both amount and currency.
If you apply discounts, calculate the final price server-side and store it in the order. The expected amount should reflect the discounted price, not the original price.
var originalPrice = product.price_kobo;
var discount = calculateDiscount(couponCode, originalPrice);
var finalPrice = originalPrice - discount;
// Store the final price as the expected amount
await db.query(
'INSERT INTO orders (reference, expected_amount, discount_applied) VALUES ($1, $2, $3)',
[reference, finalPrice, discount]
);
If you pass charges to the customer (Paystack's "charge_bearer" option), the customer pays the order amount plus fees. But the amount returned by the Paystack API is the amount that was charged, not the amount you receive. Make sure your comparison accounts for this.
Your webhook handler needs the same amount validation as your callback URL handler. Many integrations use webhooks as the primary or sole confirmation mechanism:
async function handleWebhookChargeSuccess(data) {
var reference = data.reference;
// Always verify with the API, even in the webhook handler
var verified = await verifyTransaction(reference);
var order = await db.query(
'SELECT expected_amount, expected_currency FROM orders WHERE reference = $1',
[reference]
);
if (!order.rows.length) {
console.error('Webhook for unknown order: ' + reference);
return;
}
if (verified.data.amount !== order.rows[0].expected_amount) {
console.error('Amount mismatch in webhook: ' + reference);
return;
}
if (verified.data.currency !== order.rows[0].expected_currency) {
console.error('Currency mismatch in webhook: ' + reference);
return;
}
// Safe to fulfill
await fulfillOrder(reference);
}
Both your callback handler and webhook handler should reach the same conclusion about whether to grant value. If they disagree (callback rejects but webhook grants), you have an inconsistency that attackers can exploit.
Every amount mismatch should be logged, tracked, and reviewed. A single mismatch might be a legitimate edge case. Repeated mismatches are an active attack.
Your security event log should capture:
Set up alerts for patterns like:
For comprehensive logging patterns that avoid leaking secrets, see logging payments without logging secrets.
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