Bonaventure OgetoBy Bonaventure Ogeto|

Verifying Amount and Currency Server-Side Before Granting Value

After verifying a Paystack transaction status is "success", compare data.amount against the expected amount stored in your database and data.currency against the expected currency. Both must match before you grant value. Amounts are in the smallest currency unit (kobo for NGN, pesewas for GHS, cents for KES/ZAR/USD). Never read expected values from the client request.

The Price Tampering Attack

Here is how it works. Your checkout page sells a course for NGN 50,000. When the customer clicks "Pay", your frontend sends a request to your server to initialize a Paystack transaction for 5,000,000 kobo (NGN 50,000 in the smallest unit). Your server calls Paystack, gets an access code, and returns it to the frontend.

An attacker intercepts that initialization request and changes the amount to 100 kobo (NGN 1). Or they skip your server entirely and call the Paystack initialize endpoint directly with their own amount. Paystack does not know what your product costs. It will happily initialize and process a NGN 1 transaction.

The payment succeeds. The transaction status is "success". If your verification handler only checks the status, the attacker just bought your NGN 50,000 course for NGN 1.

This attack takes about two minutes with browser DevTools. It does not require any special tools or skills. Any developer (or motivated non-developer following a YouTube tutorial) can do it.

For the full verification pipeline including this check, see the complete verification and reconciliation guide.

The Three-Part Check

A proper verification handler checks three things, in this order:

  1. Status: Is data.status equal to "success"?
  2. Amount: Does data.amount match the expected amount in your database?
  3. Currency: Does data.currency match the expected currency in your database?

All three must pass. If any one fails, do not grant value.

// The three-part verification check
function isPaymentValid(paystackData, expectedAmount, expectedCurrency) {
  // Check 1: Did the payment succeed?
  if (paystackData.status !== 'success') {
    return { valid: false, reason: 'Transaction status: ' + paystackData.status };
  }

  // Check 2: Is the amount correct?
  if (paystackData.amount !== expectedAmount) {
    return {
      valid: false,
      reason: 'Amount mismatch: expected ' + expectedAmount + ', got ' + paystackData.amount,
    };
  }

  // Check 3: Is the currency correct?
  if (paystackData.currency !== expectedCurrency) {
    return {
      valid: false,
      reason: 'Currency mismatch: expected ' + expectedCurrency + ', got ' + paystackData.currency,
    };
  }

  return { valid: true };
}

This function takes the Paystack verification response data and the expected values from your database. It returns a clear result with a reason if validation fails.

Where Expected Values Must Come From

The expected amount and currency must come from your database. Not from the client request. Not from a hidden form field. Not from a URL parameter. Your database.

The flow works like this:

  1. Customer selects a product on your frontend.
  2. Frontend sends a request to your server: "I want to buy product X".
  3. Your server looks up product X in the database, finds the price (say, 5,000,000 kobo for NGN 50,000).
  4. Your server creates an order record with the expected amount, expected currency, and a unique reference.
  5. Your server calls Paystack to initialize a transaction with that amount and reference.
  6. After payment, your server verifies by calling GET /transaction/verify/:reference.
  7. Your server reads the expected amount and currency from the order record (step 4) and compares against the Paystack response.
// Step 3-5: Initialize transaction with stored expected values
app.post('/api/create-order', async (req, res) => {
  var productId = req.body.product_id;

  // Get price from YOUR database, not from the client
  var product = await db.query(
    'SELECT price_kobo, currency FROM products WHERE id = $1',
    [productId]
  );

  if (product.rows.length === 0) {
    return res.status(404).json({ error: 'Product not found' });
  }

  var price = product.rows[0].price_kobo;
  var currency = product.rows[0].currency;
  var reference = 'order_' + productId + '_' + Date.now();

  // Store the expected values
  await db.query(
    'INSERT INTO orders (reference, product_id, expected_amount, expected_currency, status) VALUES ($1, $2, $3, $4, $5)',
    [reference, productId, price, currency, 'pending']
  );

  // Initialize with Paystack
  var paystackRes = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount: price,
      currency: currency,
      reference: reference,
      email: req.body.email,
    }),
  });

  var data = await paystackRes.json();
  return res.json({ authorization_url: data.data.authorization_url });
});

The price comes from the products table. The client only sends the product ID. Even if the attacker changes the product ID to a cheaper product, the reference and order record will not match after verification.

Working with the Smallest Currency Unit

Paystack returns amounts in the smallest currency unit. This means:

  • NGN: Amount is in kobo. 1 naira = 100 kobo. So NGN 50,000 = 5,000,000 kobo.
  • GHS: Amount is in pesewas. 1 cedi = 100 pesewas. So GHS 500 = 50,000 pesewas.
  • KES: Amount is in cents. 1 shilling = 100 cents. So KES 5,000 = 500,000 cents.
  • ZAR: Amount is in cents. 1 rand = 100 cents. So ZAR 1,000 = 100,000 cents.
  • USD: Amount is in cents. 1 dollar = 100 cents. So USD 50 = 5,000 cents.

Store amounts in your database in the same unit. If you store NGN 50,000 as the integer 5000000, the comparison against Paystack is a simple integer equality check. No floating-point math. No rounding errors. No "is 49999.9999 close enough to 50000" questions.

// Integer comparison - clean and reliable
if (paystackData.amount !== order.expected_amount) {
  // This is a mismatch. Period.
}

// Never do this:
// if (Math.abs(paystackAmount / 100 - expectedNaira) > 0.01) { ... }
// Floating-point comparison is fragile and unnecessary

If your application displays prices to users in the major unit (naira, cedi, shillings), convert only for display. All storage and comparison should happen in the smallest unit. For more on this, see the kobo, pesewa, and cents guide.

Why the Currency Check Matters

If your business only accepts one currency, you might think the currency check is unnecessary. You would be wrong.

Consider this scenario. Your application accepts NGN. You set up a second Paystack integration for a GHS product but share the same webhook endpoint. A GHS transaction comes in. Your webhook handler sees status: "success" and amount: 500000. If you only check the amount, you might match this against an NGN order expecting 500,000 kobo (NGN 5,000). But the payment was 500,000 pesewas (GHS 5,000), which is worth much less. Or much more, depending on exchange rates.

Even if you only accept one currency today, add the currency check now. When you expand to another country (which Paystack makes easy), you will be glad the check is already in place.

// Always check currency, even for single-currency apps
if (txn.currency !== order.expected_currency) {
  console.error(
    'Currency mismatch for ref ' + order.reference +
    ': expected ' + order.expected_currency +
    ', got ' + txn.currency
  );

  // Alert the team - this should never happen in normal operation
  await alertOps('Currency mismatch detected', {
    reference: order.reference,
    expected: order.expected_currency,
    actual: txn.currency,
  });

  return { valid: false };
}

For businesses operating across multiple Paystack-supported countries, see the multi-country currency accounting guide.

Complete Verification Handler with All Checks

Here is the full verification handler that brings together every check discussed above. This is the pattern you should use in production:

// routes/verify.js - Production verification handler
async function handleVerification(reference) {
  // 1. Find the order
  var orderResult = await db.query(
    'SELECT id, expected_amount, expected_currency, status, product_id FROM orders WHERE paystack_reference = $1',
    [reference]
  );

  if (orderResult.rows.length === 0) {
    return { success: false, error: 'order_not_found' };
  }

  var order = orderResult.rows[0];

  // 2. Idempotency check
  if (order.status === 'paid') {
    return { success: true, already_paid: true, order_id: order.id };
  }

  // 3. Call Paystack
  var verifyRes = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    {
      headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY },
    }
  );

  var payload = await verifyRes.json();

  if (!payload.status) {
    return { success: false, error: 'paystack_api_error', message: payload.message };
  }

  var txn = payload.data;

  // 4. Status check
  if (txn.status !== 'success') {
    await db.query(
      'UPDATE orders SET paystack_status = $1 WHERE id = $2',
      [txn.status, order.id]
    );
    return { success: false, error: 'payment_not_successful', paystack_status: txn.status };
  }

  // 5. Amount check
  if (txn.amount !== order.expected_amount) {
    console.error(
      'AMOUNT MISMATCH ref=' + reference +
      ' expected=' + order.expected_amount +
      ' got=' + txn.amount
    );
    await db.query(
      'INSERT INTO verification_alerts (reference, alert_type, details) VALUES ($1, $2, $3)',
      [reference, 'amount_mismatch', JSON.stringify({ expected: order.expected_amount, actual: txn.amount })]
    );
    return { success: false, error: 'amount_mismatch' };
  }

  // 6. Currency check
  if (txn.currency !== order.expected_currency) {
    console.error(
      'CURRENCY MISMATCH ref=' + reference +
      ' expected=' + order.expected_currency +
      ' got=' + txn.currency
    );
    await db.query(
      'INSERT INTO verification_alerts (reference, alert_type, details) VALUES ($1, $2, $3)',
      [reference, 'currency_mismatch', JSON.stringify({ expected: order.expected_currency, actual: txn.currency })]
    );
    return { success: false, error: 'currency_mismatch' };
  }

  // 7. All checks passed - grant value atomically
  await db.query('BEGIN');
  await db.query(
    'UPDATE orders SET status = $1, paid_at = $2, paystack_fees = $3, channel = $4 WHERE id = $5 AND status != $6',
    ['paid', txn.paid_at, txn.fees, txn.channel, order.id, 'paid']
  );
  // Grant product access, send email, etc.
  await db.query('COMMIT');

  return { success: true, order_id: order.id };
}

This handler logs mismatches to a verification_alerts table so your team can investigate. In production, you would also send a Slack notification or email for any mismatch, because they are rare and always worth investigating.

Edge Cases Worth Knowing

Partial debits. Some card issuers in Nigeria allow partial debits, where the bank charges less than the requested amount because the account balance is insufficient. Paystack supports this with a flag. If you use partial debits, the data.amount in the verification response will be less than what you initialized. Your standard amount check will reject it. You need to handle partial debits separately, deciding whether to accept a partial payment or reject it. See the partial debits guide for details.

Split payments. If you use Paystack split payments, the data.amount is still the full amount the customer paid. The split happens on Paystack's side during settlement. Your amount check works normally.

Fees and net amount. The data.fees field shows Paystack's processing fee. The amount the customer paid is data.amount. The amount you receive in settlement is data.amount - data.fees. Your verification should compare against data.amount (the gross amount), not the net. You handle fees separately in your ledger.

Rounding on converted amounts. If your product is priced in USD and the customer pays in NGN, the conversion might introduce rounding. In practice, Paystack handles this during initialization, and the amount in the verification response matches what was initialized. Your check should still pass.

Key Takeaways

  • Always compare the amount returned by Paystack verification against the amount stored in your database, not against a value from the client.
  • Check currency as well as amount. A payment of 5000 KES is very different from 5000 NGN.
  • Store expected amounts in the smallest currency unit (kobo, pesewas, cents) to match what Paystack returns.
  • Price tampering attacks are real and simple. An attacker modifies the initialization request to change the amount to a tiny value.
  • The server that initializes the transaction should store the expected amount. The server that verifies should read it back from the database.
  • Amount mismatches can also indicate bugs in your code, not just attacks. Log them with full context for debugging.

Frequently Asked Questions

Can an attacker change the amount after the transaction is initialized?
No. Once a transaction is initialized with Paystack, the amount is locked. The attack vector is intercepting the initialization request before it reaches Paystack, or calling the Paystack initialize endpoint directly with a different amount. That is why your server must be the one calling initialize, and the expected amount must come from your database.
Should I compare amounts as integers or floating-point numbers?
Always compare as integers. Store amounts in the smallest currency unit (kobo, pesewas, cents) and compare with strict equality. Floating-point comparison introduces rounding bugs that are hard to debug.
What if I use Paystack Payment Pages and do not control the initialization?
Paystack Payment Pages have a fixed amount set in the dashboard. The customer cannot change it. However, you should still verify the amount in your webhook handler, because Payment Pages support variable amounts (where the customer enters their own amount). Always verify.
How do I handle discounts or promo codes with amount verification?
Apply the discount on your server before initializing the transaction. Store the discounted amount as the expected amount in your order record. The verification check then compares against the discounted amount. Never apply discounts on the client side.
Should I log amount mismatches differently from currency mismatches?
Yes. Amount mismatches often indicate price tampering or a bug in your initialization code. Currency mismatches usually indicate a misconfigured integration or a routing error. Log both with full context and alert your team immediately.

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