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.
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:
Reconciliation catches all of these. Without it, you are running blind. With it, you catch discrepancies before they become customer complaints or accounting errors.
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
The lesson is clear: invest in good references upfront. They save you from painful, ambiguous matching later.
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:
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:
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:
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:
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 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.
Here is the workflow that works in production. Adapt the timing and channels to your team's schedule.
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.
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:
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.
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.
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