Bonaventure OgetoBy Bonaventure Ogeto|

Reconciling M-Pesa Payments Received Through Paystack

To reconcile M-Pesa payments through Paystack, export your Paystack transactions for the day via the API or dashboard, then compare each transaction against your internal database by reference. Match on reference first, then cross-check amount and status. Flag any transaction that exists in Paystack but not in your database (missed webhooks) or in your database but not in Paystack (phantom records). Run this job daily, and run a real-time sweeper every few minutes for pending transactions.

Why You Need Reconciliation for M-Pesa on Paystack

When a customer pays via M-Pesa through Paystack, the payment touches three systems: Safaricom (M-Pesa), Paystack, and your application. Each system has its own record of what happened. Most of the time, all three agree. The customer paid KES 1,500, Paystack recorded a successful charge, and your database shows the order as paid.

But sometimes they disagree. And "sometimes" in a system that processes hundreds or thousands of payments a day means multiple discrepancies per week. Here are the real scenarios that cause disagreements:

  • The webhook did not arrive. Paystack sent it, but your server was briefly down, or a network issue swallowed it. Paystack will retry, but retries are not instant and they have limits.
  • The webhook arrived but was not processed. Your handler threw an error, returned a non-200 status, or timed out. Paystack sees this as a failed delivery and will retry, but your database has no record of the event.
  • The STK push timed out on your side but succeeded on Safaricom's side. The customer entered their PIN at the last second. Your system marked the payment as failed. Paystack recorded it as successful. The money moved, but your application does not know.
  • A transaction was reversed. The customer disputed the charge, or Safaricom reversed the M-Pesa transaction for a technical reason. Paystack reflects the reversal, but if your system does not handle the reversal webhook, your records still show "paid."
  • Duplicate processing. Your webhook handler processed the same event twice (maybe it was not idempotent). Your database now shows two payments for one order, but Paystack only charged the customer once.

Reconciliation catches all of these. Without it, you are running blind. With it, you catch discrepancies before they become customer complaints or accounting errors.

What Paystack Gives You to Work With

Paystack provides several ways to access your transaction data for reconciliation:

1. The List Transactions API

You can fetch transactions for a date range using the Transactions API. This is the primary tool for automated reconciliation.

// Fetch all transactions for a specific date
async function fetchPaystackTransactions(date) {
  const from = `${date}T00:00:00.000Z`;
  const to = `${date}T23:59:59.999Z`;
  let page = 1;
  let allTransactions = [];

  while (true) {
    const res = await fetch(
      `https://api.paystack.co/transaction?from=${from}&to=${to}&perPage=100&page=${page}&status=success`,
      {
        headers: {
          Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
        },
      }
    );

    const data = await res.json();
    allTransactions = allTransactions.concat(data.data);

    if (data.data.length < 100) break;
    page++;
  }

  return allTransactions;
}

Each transaction object from Paystack includes:

  • reference - The reference you set when initializing the transaction. This is your primary matching key.
  • amount - The amount in the smallest currency unit (cents for KES). KES 1,500 appears as 150000.
  • currency - Should be "KES" for Kenyan transactions.
  • status - "success", "failed", "abandoned", or "reversed".
  • channel - "mobile_money" for M-Pesa payments. This is how you distinguish M-Pesa from card or bank transfer payments.
  • paid_at - The timestamp when the payment completed.
  • fees - The Paystack fee deducted from this transaction.
  • authorization.channel and authorization.bank - For M-Pesa, the bank field often shows "MPESA" or similar.

2. The Dashboard Export

Paystack's dashboard lets you export transactions as a CSV. This is useful for manual reconciliation or for handing to your finance team. It includes the same fields as the API but in spreadsheet format.

3. The Transaction Export API

For large volumes, Paystack provides an export endpoint that generates a downloadable file. This is better than paginating through the List API when you have thousands of transactions per day.

What Paystack does not give you: An M-Pesa confirmation code. When a customer pays via M-Pesa, they receive an M-Pesa confirmation SMS with a code like "SIF3ABCDEF." Paystack does not expose this code in the transaction data. This means you cannot match directly against the customer's M-Pesa SMS. You match against Paystack's records, and Paystack matches against Safaricom's records internally.

How to Match Transactions

The quality of your reconciliation depends entirely on the quality of your transaction references. If you set meaningful, unique references when initializing transactions, matching is simple. If you let Paystack auto-generate references, matching is harder.

Best practice: deterministic references.

When you initialize a Paystack transaction, set the reference to something that ties directly to your internal order or invoice. For example: order_4582_1721472000 (order ID plus Unix timestamp). This way, given a Paystack transaction, you can immediately look up the corresponding order in your database.

Matching by reference (primary method):

async function reconcileByReference(paystackTransactions) {
  const results = {
    matched: [],
    missingInDb: [],       // In Paystack, not in our DB
    missingInPaystack: [], // In our DB, not in Paystack
    amountMismatch: [],
    statusMismatch: [],
  };

  // Step 1: Check each Paystack transaction against our DB
  for (const psTxn of paystackTransactions) {
    const dbRecord = await db.findPaymentByReference(psTxn.reference);

    if (!dbRecord) {
      results.missingInDb.push({
        reference: psTxn.reference,
        amount: psTxn.amount,
        paidAt: psTxn.paid_at,
        channel: psTxn.channel,
      });
      continue;
    }

    // Compare amounts (both should be in cents)
    if (dbRecord.amountInCents !== psTxn.amount) {
      results.amountMismatch.push({
        reference: psTxn.reference,
        paystackAmount: psTxn.amount,
        dbAmount: dbRecord.amountInCents,
      });
      continue;
    }

    // Compare statuses
    if (psTxn.status === 'success' && dbRecord.status !== 'paid') {
      results.statusMismatch.push({
        reference: psTxn.reference,
        paystackStatus: psTxn.status,
        dbStatus: dbRecord.status,
      });
      continue;
    }

    results.matched.push(psTxn.reference);
  }

  // Step 2: Check for DB records with no Paystack counterpart
  const paystackRefs = new Set(paystackTransactions.map((t) => t.reference));
  const dbPendingOrPaid = await db.getPaymentsForDate(reconciliationDate);

  for (const dbRecord of dbPendingOrPaid) {
    if (!paystackRefs.has(dbRecord.reference)) {
      results.missingInPaystack.push({
        reference: dbRecord.reference,
        amount: dbRecord.amountInCents,
        status: dbRecord.status,
        createdAt: dbRecord.createdAt,
      });
    }
  }

  return results;
}

Matching by amount + time (fallback method):

If you did not set references (or you are reconciling legacy data), you fall back to matching by amount and timestamp. This is less reliable because two customers could pay the same amount within the same minute. But it is better than nothing.

  • Find Paystack transactions with no matching reference in your database.
  • For each, search your database for orders with the same amount that were created within a reasonable time window (say, 30 minutes before the Paystack transaction timestamp).
  • If you find exactly one match, it is probably the right one. Flag it for manual confirmation.
  • If you find zero or multiple matches, escalate for manual review.

The lesson is clear: invest in good references upfront. They save you from painful, ambiguous matching later.

Handling Each Type of Discrepancy

Your reconciliation job will surface discrepancies. Here is how to handle each type:

Transaction in Paystack, not in your database (missed webhook):

This is the most common M-Pesa discrepancy. It means the customer paid, Paystack recorded it, but your webhook handler never processed it. The fix:

  1. Verify the transaction on Paystack's API to confirm it is genuine.
  2. Extract the reference and match it to an order in your system.
  3. Grant the customer their value (activate their subscription, mark the order as paid, etc.).
  4. Log the reconciliation action so you have an audit trail.
  5. Investigate why the webhook failed. Check your webhook logs, server uptime, and error logs for that time window.

Transaction in your database, not in Paystack (phantom record):

This usually means the transaction was initialized but never completed. The customer started the checkout flow, your system created a pending payment record, but the customer never entered their M-Pesa PIN (or the STK push timed out). The fix:

  1. If the record is still marked as "pending," update it to "expired" or "abandoned."
  2. If the record is somehow marked as "paid" but Paystack has no record of it, investigate immediately. This could be a bug in your webhook handler or, in rare cases, an attempt to manipulate your system.

Amount mismatch:

The Paystack transaction shows a different amount than your database expects. This is rare with properly coded integrations, but it can happen if:

  • Your frontend allows the user to modify the amount before paying (a security vulnerability).
  • A currency conversion issue caused rounding differences.
  • You are comparing the wrong unit (KES vs cents). Remember, Paystack amounts are in the smallest currency unit.

The fix depends on the root cause. If the amounts are off by a few cents due to rounding, you might accept it with a tolerance. If the amounts are substantially different, investigate the code path that generated the transaction.

Reversed transaction:

The Paystack transaction status is "reversed" but your database still shows "paid." This means the M-Pesa transaction was reversed by Safaricom (or Paystack processed a reversal). Your reconciliation job should:

  1. Update the payment status in your database to "reversed."
  2. Revoke the value you granted (deactivate the subscription, cancel the order, etc.).
  3. Notify the customer that their payment was reversed and they need to pay again.

A Working Reconciliation Script

Here is a complete daily reconciliation script that you can adapt for your system. It fetches the previous day's transactions from Paystack, compares them against your database, and generates a report.

const crypto = require('crypto');

// Configuration
const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;
const AMOUNT_TOLERANCE_CENTS = 0; // Set to a small number if you expect rounding issues

// Fetch all successful Paystack transactions for a date
async function fetchPaystackTransactionsForDate(dateStr) {
  const from = `${dateStr}T00:00:00.000Z`;
  const to = `${dateStr}T23:59:59.999Z`;
  let page = 1;
  const all = [];

  while (true) {
    const url = new URL('https://api.paystack.co/transaction');
    url.searchParams.set('from', from);
    url.searchParams.set('to', to);
    url.searchParams.set('perPage', '100');
    url.searchParams.set('page', String(page));

    const res = await fetch(url.toString(), {
      headers: { Authorization: `Bearer ${PAYSTACK_SECRET}` },
    });

    const data = await res.json();
    if (!data.status) throw new Error(`Paystack API error: ${data.message}`);

    all.push(...data.data);
    if (data.data.length < 100) break;
    page++;
  }

  return all;
}

// Main reconciliation function
async function runDailyReconciliation(dateStr) {
  console.log(`Running reconciliation for ${dateStr}`);

  const paystackTxns = await fetchPaystackTransactionsForDate(dateStr);
  console.log(`Fetched ${paystackTxns.length} Paystack transactions`);

  // Filter to M-Pesa transactions only (optional, remove to reconcile all channels)
  const mpesaTxns = paystackTxns.filter(
    (t) => t.channel === 'mobile_money'
  );
  console.log(`Of which ${mpesaTxns.length} are mobile money (M-Pesa)`);

  const report = {
    date: dateStr,
    totalPaystack: mpesaTxns.length,
    matched: 0,
    missingInDb: [],
    missingInPaystack: [],
    amountMismatch: [],
    statusMismatch: [],
    reversed: [],
  };

  // Compare Paystack transactions against our DB
  const paystackRefs = new Set();

  for (const psTxn of mpesaTxns) {
    paystackRefs.add(psTxn.reference);

    const dbRecord = await db.payments.findOne({
      where: { reference: psTxn.reference },
    });

    if (!dbRecord) {
      report.missingInDb.push({
        reference: psTxn.reference,
        amount: psTxn.amount,
        currency: psTxn.currency,
        status: psTxn.status,
        channel: psTxn.channel,
        paidAt: psTxn.paid_at,
        customerEmail: psTxn.customer?.email,
      });
      continue;
    }

    // Check for reversals
    if (psTxn.status === 'reversed' && dbRecord.status === 'paid') {
      report.reversed.push({
        reference: psTxn.reference,
        amount: psTxn.amount,
        dbStatus: dbRecord.status,
      });
      continue;
    }

    // Check amount
    const amountDiff = Math.abs(dbRecord.amountInCents - psTxn.amount);
    if (amountDiff > AMOUNT_TOLERANCE_CENTS) {
      report.amountMismatch.push({
        reference: psTxn.reference,
        paystackAmount: psTxn.amount,
        dbAmount: dbRecord.amountInCents,
        difference: amountDiff,
      });
      continue;
    }

    // Check status
    if (psTxn.status === 'success' && dbRecord.status !== 'paid') {
      report.statusMismatch.push({
        reference: psTxn.reference,
        paystackStatus: psTxn.status,
        dbStatus: dbRecord.status,
      });
      continue;
    }

    report.matched++;
  }

  // Check for DB records with no Paystack counterpart
  const dbPayments = await db.payments.findAll({
    where: {
      createdAt: { between: [`${dateStr}T00:00:00Z`, `${dateStr}T23:59:59Z`] },
      channel: 'mobile_money',
      status: { in: ['paid', 'pending'] },
    },
  });

  for (const dbRecord of dbPayments) {
    if (!paystackRefs.has(dbRecord.reference)) {
      report.missingInPaystack.push({
        reference: dbRecord.reference,
        amount: dbRecord.amountInCents,
        status: dbRecord.status,
        createdAt: dbRecord.createdAt,
      });
    }
  }

  return report;
}

// Auto-resolve clear-cut cases
async function autoResolve(report) {
  let resolved = 0;

  // Resolve missed webhooks: transaction succeeded in Paystack but missing from our DB
  for (const missing of report.missingInDb) {
    if (missing.status === 'success') {
      // Verify once more directly
      const verifyRes = await fetch(
        `https://api.paystack.co/transaction/verify/${missing.reference}`,
        { headers: { Authorization: `Bearer ${PAYSTACK_SECRET}` } }
      );
      const verified = await verifyRes.json();

      if (verified.data.status === 'success') {
        await processLatePayment(missing.reference, verified.data);
        resolved++;
      }
    }
  }

  // Resolve status mismatches: Paystack says success, we say pending
  for (const mismatch of report.statusMismatch) {
    if (mismatch.paystackStatus === 'success' && mismatch.dbStatus === 'pending') {
      await db.payments.update(
        { status: 'paid', reconciledAt: new Date() },
        { where: { reference: mismatch.reference } }
      );
      await grantValue(mismatch.reference);
      resolved++;
    }
  }

  console.log(`Auto-resolved ${resolved} discrepancies`);
  return resolved;
}

This script is a starting point. In production, you would add error handling, logging, and alerting. You would send the report to Slack or email so your operations team sees it every morning. And you would store the report in your database so you have a history of reconciliation results over time.

M-Pesa Edge Cases in Paystack Reconciliation

M-Pesa payments through Paystack have specific edge cases that card payments do not:

Late STK push completions. The customer enters their PIN right at the timeout boundary. Safaricom debits the money, but the webhook arrives minutes later (or not at all). Your real-time sweeper job catches these if it runs frequently enough. The daily reconciliation catches the rest.

M-Pesa reversals. Safaricom can reverse an M-Pesa transaction after it was reported as successful. This is rare but it happens. The customer might call Safaricom's support line and claim they did not authorize the payment. Paystack reflects the reversal, but there may be a delay. Your daily reconciliation should check for transactions that were "success" when you last checked but are now "reversed."

Phone number mismatches. A customer might start the payment flow with one phone number but enter a different one for the M-Pesa payment. The Paystack transaction records the phone number that was actually charged. If your database recorded the phone number from the checkout form, these might not match. This is not a reconciliation error, but it can cause confusion when customer support investigates a payment.

Same amount, same minute. Two different customers pay KES 500 within the same minute via M-Pesa. If you are doing fallback matching by amount and time (because references are missing), these two transactions are ambiguous. This is another reason to always set unique references.

Paystack fee deductions. Paystack deducts its fee before settlement. The amount the customer paid (what Paystack records) is different from the amount that arrives in your bank account. Your reconciliation should compare against the gross amount (what the customer paid), not the net amount (what you received in settlement). Settlement reconciliation is a separate process. See Reconciling Paystack Settlements Against Your Database.

Test mode transactions. If someone accidentally ran a transaction against your test keys, it appears in your test dashboard but not in live. Make sure your reconciliation script uses the correct API key (live vs test) for the environment you are reconciling.

The Daily Reconciliation Workflow

Here is the workflow that works in production. Adapt the timing and channels to your team's schedule.

  1. Every morning at 6 AM (or whenever your operations day starts): The reconciliation job runs automatically. It fetches the previous day's transactions from Paystack and compares them against your database.
  2. Auto-resolve clear cases: Missed webhooks where Paystack shows success are auto-resolved. The script grants the customer their value, updates the database, and logs the action.
  3. Generate a report: The remaining discrepancies (amount mismatches, phantom records, reversals, ambiguous matches) go into a report.
  4. Send the report to your team: Slack message, email, or a dashboard entry. Your operations or finance team reviews it.
  5. Manual resolution: Someone on your team investigates each unresolved discrepancy. They check Paystack's dashboard, contact the customer if needed, and update the records.
  6. Close the day: Once all discrepancies are resolved (or marked as "investigated, no action needed"), the reconciliation for that day is complete.

Some teams run reconciliation more than once a day. If you process high volume, consider a midday run as well. The real-time sweeper job (running every 2 to 5 minutes) catches urgent cases, but it only looks at pending transactions. The daily batch job catches everything else.

Store your reconciliation reports. A month from now, when a customer complains about a payment from three weeks ago, you will be glad you have a record of exactly what your system knew at reconciliation time.

Paystack Records vs M-Pesa Statement

A question that comes up often: should you reconcile against Paystack's records or against your M-Pesa statement?

The answer is Paystack's records. Here is why.

When a customer pays via M-Pesa through Paystack, the money goes to Paystack's M-Pesa paybill, not yours. You do not have a direct M-Pesa statement for these transactions. Paystack aggregates the M-Pesa payments across all their merchants and then settles to your bank account in batches.

Your reconciliation chain looks like this:

  1. Your database vs Paystack transactions (this is the reconciliation described in this article)
  2. Paystack transactions vs Paystack settlements (making sure the settled amount matches the sum of transactions minus fees)
  3. Paystack settlements vs your bank statement (making sure the money actually arrived)

If you use direct Daraja alongside Paystack, then you do reconcile against your own M-Pesa statement for those direct transactions. But for anything that went through Paystack, Paystack's transaction records are your source of truth. You trust Paystack to have reconciled against Safaricom on their end.

For the settlement reconciliation step, see Reconciling Paystack Settlements Against Your Database and Paystack Settlement Timelines and What Engineers Should Assume.

Building Reconciliation Into Your System

Reconciliation should not be an afterthought bolted on after launch. Build it into your system from day one. Here is the minimal infrastructure you need:

A payments table that tracks everything. Store the reference, amount, currency, status, channel, customer identifier, Paystack transaction ID, creation timestamp, payment timestamp, and reconciliation timestamp. Every state change gets a new row or an update with a timestamp.

A webhook log. Store every raw webhook payload Paystack sends you. When a discrepancy arises, you need to know exactly what Paystack sent and when. See Storing Raw Paystack Webhook Payloads for Audit.

A reconciliation log. Each time the reconciliation job runs, store the results. How many matched, how many discrepancies, what was auto-resolved, what was escalated. This gives you trends over time. If your discrepancy rate is climbing, something in your system is degrading.

Alerts. If the reconciliation job finds more than your normal threshold of discrepancies, send an alert. If a high-value transaction is missing from your database, alert immediately. Do not wait for the morning report.

Reconciliation is not glamorous work. But it is the difference between a payment system that "works" and a payment system you can trust. For Kenyan M-Pesa payments through Paystack, where the transaction flows through Safaricom, Paystack, and your application, reconciliation is how you keep all three systems honest.

For the general Paystack reconciliation pattern (covering all payment channels, not just M-Pesa), see Building a Daily Paystack Reconciliation Job.

Key Takeaways

  • Reconciliation means comparing your internal payment records against Paystack's records and resolving any differences. It is not optional for any business that processes real money.
  • The primary matching key is the transaction reference. If you generate unique, deterministic references for each order, matching becomes straightforward. If your references are random or duplicated, matching becomes painful.
  • The three reconciliation scenarios are: transaction in both systems (happy path), transaction in Paystack but not in your database (missed webhook), and transaction in your database but not in Paystack (phantom or abandoned).
  • M-Pesa payments through Paystack have specific edge cases: STK push timeouts that complete late, reversed transactions after successful webhooks, and partial payment scenarios when the customer pays from a wallet with insufficient balance.
  • Run two reconciliation layers. A real-time sweeper that checks pending transactions every few minutes, and a daily batch job that compares the full day's records.
  • Store raw Paystack webhook payloads. When a discrepancy arises during reconciliation, the raw payload is your evidence for debugging.
  • Paystack fees are deducted before settlement. Your reconciliation must compare the gross transaction amount (what the customer paid) against your expected amount, not the net settlement amount.

Frequently Asked Questions

Can I get the M-Pesa confirmation code from Paystack for reconciliation?
Paystack does not expose the Safaricom M-Pesa confirmation code (the alphanumeric code the customer receives via SMS) in its transaction data. You reconcile against Paystack's transaction records using your reference, not against the M-Pesa confirmation code. If you need the M-Pesa confirmation code for your records, you would need a direct Daraja integration alongside Paystack.
How often should I run the reconciliation job?
Run a real-time sweeper every 2 to 5 minutes to catch pending transactions that completed without a webhook. Run the full daily reconciliation job once every morning. If you process high volume, consider a midday batch as well. The sweeper catches urgent cases quickly. The daily job catches everything else.
What do I do when I find a payment in Paystack that is not in my database?
Verify the transaction on Paystack's API to confirm it is genuine. Match the reference to an order in your system. If it matches a real order, grant the customer their value (activate the service, mark the order as paid, send a confirmation). Log the reconciliation action. Then investigate why the webhook was missed so you can prevent it from happening again.
Should I compare the net or gross amount during reconciliation?
Compare the gross amount (what the customer paid, which is what Paystack records in the transaction amount). Paystack fees are deducted before settlement, so the settlement amount is different from the transaction amount. Transaction reconciliation uses the gross amount. Settlement reconciliation is a separate step where you account for fees.
How do I handle reversed M-Pesa transactions in reconciliation?
When your reconciliation job finds a transaction that Paystack now shows as "reversed" but your database shows as "paid," update your database status to "reversed," revoke the value you granted (cancel the order, deactivate the subscription), and notify the customer. Store the reversal details for your audit trail. Reversals are rare for M-Pesa but they do happen.

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