Reconciling Payouts Against Recipient Confirmations
Query GET /transfer with status=success and compare against your internal transfers table. Match on transfer_code. For each transfer, check recipient_integration.recipient_code and the actual amount. Store transfer.success webhook data in a transfers table, then run a daily reconciliation that calls GET /transfer/:id to confirm the final bank-side status. Flag any transfer with status=reversed for manual review.
Transfer Lifecycle: Dispatch vs Confirmation
There are two distinct events in a Paystack transfer:
- transfer.success webhook — Paystack processed the transfer and it left your balance.
- Bank credit — The receiving bank processes the deposit. This can take minutes to hours depending on the bank and transfer type.
Paystack sends one transfer.success when its processing is done. You do not receive a separate event when the recipient's bank credits the account. If the bank rejects it, Paystack fires transfer.reversed and refunds your balance.
Your reconciliation must account for both the initial success and any subsequent reversal.
Transfers Database Table
-- Store every transfer event in this table
CREATE TABLE transfers (
id SERIAL PRIMARY KEY,
transfer_code TEXT UNIQUE NOT NULL,
reference TEXT,
recipient_code TEXT NOT NULL,
recipient_name TEXT,
bank_name TEXT,
account_number TEXT,
amount NUMERIC(12, 2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'NGN',
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending' | 'success' | 'reversed' | 'failed'
initiated_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
reversed_at TIMESTAMPTZ,
paystack_created_at TIMESTAMPTZ,
notes TEXT,
raw_webhook JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Handling Transfer Webhooks
// Webhook handler for transfer events
if (event.event === 'transfer.success') {
var t = event.data;
await db.transfers.upsert({
transfer_code: t.transfer_code,
reference: t.reference,
recipient_code: t.recipient.recipient_code,
recipient_name: t.recipient.details?.account_name,
bank_name: t.recipient.details?.bank_name,
account_number: t.recipient.details?.account_number,
amount: t.amount / 100,
currency: t.currency,
status: 'success',
completed_at: t.updated_at,
raw_webhook: t,
}, { conflictField: 'transfer_code' });
}
if (event.event === 'transfer.reversed') {
await db.transfers.update(
{ status: 'reversed', reversed_at: event.data.updated_at },
{ where: { transfer_code: event.data.transfer_code } }
);
// Alert: money returned to balance — needs manual review or re-payout
console.error('Transfer reversed:', event.data.transfer_code, event.data.failure_reason);
}
if (event.event === 'transfer.failed') {
await db.transfers.update(
{ status: 'failed' },
{ where: { transfer_code: event.data.transfer_code } }
);
}
Daily Reconciliation Against Paystack API
// reconcile-transfers.js — run daily
async function reconcileTransfers() {
// 1. Fetch all transfers from Paystack for the last 7 days
var response = await fetch(
'https://api.paystack.co/transfer?perPage=100&page=1',
{ headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY } }
);
var { data } = await response.json();
var mismatches = [];
for (var pt of data) {
var localTransfer = await db.transfers.findOne({
where: { transfer_code: pt.transfer_code }
});
if (!localTransfer) {
mismatches.push({ type: 'missing_local', transfer_code: pt.transfer_code });
continue;
}
// Status mismatch
if (localTransfer.status !== pt.status) {
mismatches.push({
type: 'status_mismatch',
transfer_code: pt.transfer_code,
local_status: localTransfer.status,
paystack_status: pt.status
});
// Update local to match Paystack
await db.transfers.update(
{ status: pt.status },
{ where: { transfer_code: pt.transfer_code } }
);
}
// Amount mismatch
var paystackAmount = pt.amount / 100;
if (Math.abs(localTransfer.amount - paystackAmount) > 0.01) {
mismatches.push({
type: 'amount_mismatch',
transfer_code: pt.transfer_code,
local: localTransfer.amount,
paystack: paystackAmount
});
}
}
if (mismatches.length > 0) {
console.error('Transfer reconciliation mismatches:', mismatches);
// Send alert to Slack/email
}
return { total: data.length, mismatches: mismatches.length };
}
Learn More
This guide is part of the Paystack transfers and payouts series.
Key Takeaways
- ✓transfer.success means Paystack dispatched the transfer — not that the recipient bank credited the account.
- ✓The final confirmed status comes via the transfer.success webhook once bank processing completes (usually minutes to hours).
- ✓Reversals (transfer.reversed) happen when the bank rejects the transfer — usually wrong account number or account closed.
- ✓Build a transfers table: store transfer_code, recipient_code, amount, status, and timestamps from every transfer webhook.
- ✓Run a daily reconciliation: call GET /transfer to list all transfers, compare amounts and statuses against your transfers table.
- ✓Flag mismatches — a transfer in your DB as success but reversed in Paystack means money came back to your balance silently.
Frequently Asked Questions
- Does Paystack notify me when the recipient actually receives the money?
- No. Paystack fires transfer.success when the transfer is processed on its end. There is no separate event for bank-side crediting. If the bank rejects the transfer, Paystack fires transfer.reversed and refunds your balance.
- How long does it take for a Paystack transfer to reach the recipient?
- NGN bank transfers typically settle within minutes to a few hours for same-bank, and up to next business day for other banks. GHs mobile money and bank transfers are usually faster. Always communicate expectations to recipients rather than exact times.
- What should I do when a transfer is reversed?
- Mark the transfer as reversed in your database, refund the recipient in your system (their vendor balance should go back up), and optionally trigger a re-payout attempt with a corrected account number. Log the failure_reason from the webhook — it usually says "Account Not Found" or "Account Closed".
- Can I query Paystack for all transfers in a date range?
- Yes. Call GET /transfer?from=2026-07-01&to=2026-07-22&perPage=100. Paginate through all pages using the page parameter. Use this for your daily reconciliation script to compare Paystack's records against your database.
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