Bonaventure OgetoBy Bonaventure Ogeto|

Auditing a Paystack Integration You Inherited

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.

Why This Audit Matters

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.

Check 1: Server-Side Verification

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.

Check 2: The Double-Grant Bug

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:

  • The webhook handler (usually a POST endpoint for Paystack webhooks)
  • The redirect callback handler (the URL Paystack redirects to after checkout)
  • A verification API endpoint called by the frontend
// 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.

Check 3: API Key Security

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:

  • The secret key is read from an environment variable: process.env.PAYSTACK_SECRET_KEY
  • The .env file is in .gitignore
  • The secret key has never been committed to git (check history with git log -p --all -S "sk_live")
  • The public key (pk_*) is the only key used in frontend code
  • Production uses live keys (sk_live_*) and test/staging uses test keys (sk_test_*)

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.

Check 4: Webhook Signature Validation

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.

Check 5: Database Schema

Examine the database schema for payment-related tables. Look for:

  • Separate payment ledger table: Does one exist, or is payment data mixed into the orders table? A separate ledger is strongly recommended for any non-trivial integration.
  • Idempotency mechanism: Is there a unique constraint on the payment reference? Without it, duplicate webhooks create duplicate records.
  • Fee tracking: Are fees stored separately from amounts? If not, settlement reconciliation is impossible without recalculation.
  • Status tracking: Does the orders table have a proper status column with distinct values for pending, paid, failed, refunded?
-- 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.

Check 6: Error Handling

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.

Check 7: Test the Full Flow

Switch to Paystack test mode (sk_test_ keys) and run through the entire payment flow:

  1. Successful payment: Pay with a test card. Verify the order is marked as paid, the customer gets the product, and the transaction is recorded.
  2. Failed payment: Use a test card that triggers a decline. Verify the order is not marked as paid and the customer sees an appropriate error.
  3. Abandoned checkout: Start checkout but close the browser. Check the order status after a few minutes.
  4. Duplicate webhook: Manually send the same webhook event twice (use a tool like curl or Postman). Verify the system handles it idempotently.
  5. Invalid webhook: Send a webhook with an invalid signature. Verify it is rejected.
  6. Amount tampering: If the frontend sends the amount to the server, try sending a different amount and verify the server uses the database price, not the client-provided price.

Document each test result. This becomes your audit evidence and guides the fix priority.

Writing the Audit Report

Organize your findings by severity:

Critical (fix today):

  • No server-side verification
  • Secret key exposed in frontend code or committed to git
  • No webhook signature validation
  • Double-grant bug present

High (fix this week):

  • Amount or currency not verified against database
  • No idempotency mechanism on payment processing
  • Empty catch blocks in payment code
  • Test keys used in production

Medium (fix this month):

  • No payments ledger (only orders table)
  • No fee tracking
  • No reconciliation process
  • No error alerting for payment failures

Low (improvement):

  • No sweeper job for lost webhooks
  • No admin payments dashboard
  • No automated testing of payment flows
  • No structured logging of payment events

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.

Key Takeaways

  • The first thing to check is whether server-side verification exists. If the application trusts frontend callbacks to grant value, it is losing money to fraud.
  • The second most common bug is the double-grant: both webhook and redirect callback granting value for the same transaction.
  • Check that API keys are in environment variables, not hardcoded in source code or committed to git.
  • Verify that webhook signature validation is implemented. Without it, anyone can send fake webhooks to your endpoint.
  • Check the database schema for proper payment recording. At minimum, you need a status column with an idempotency mechanism.
  • Test the full payment flow in Paystack test mode. Pay with test cards, simulate failures, and verify the system handles each case correctly.
  • Document everything you find. The audit report becomes the roadmap for fixing the integration.

Frequently Asked Questions

Should I rewrite the integration from scratch or fix it incrementally?
Fix it incrementally, starting with the critical issues. A complete rewrite risks introducing new bugs and takes longer to deploy. Fix verification first, then the double-grant bug, then key security. Each fix can be deployed independently.
How do I know if money has already been lost?
Compare the number of orders marked as "paid" in your database against the total successful transactions on the Paystack dashboard for the same period. If your database shows more paid orders than Paystack shows successful transactions, value was granted without payment.
What if I find the secret key committed to git history?
Rotate the key immediately in the Paystack dashboard. The old key becomes invalid. Update your production environment with the new key. Do not try to rewrite git history, as that is disruptive and the key is already compromised.
How do I audit without access to the Paystack dashboard?
Ask the business owner for Paystack dashboard access or API keys. You need the secret key to run the audit tests. If you cannot get dashboard access, you can still audit the codebase for structural issues (verification, key security, double-grant) without actually calling the API.
Should I tell the client or business owner about the bugs before fixing them?
Yes. Document the findings and their financial impact. Critical bugs like missing verification mean the business may have already lost money. The business owner needs to know so they can assess the damage and prioritize the fixes.

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