Resolve every bank account with Paystack before sending money to it. Compare the resolved name against the user profile name. Flag mismatches for manual review. Enforce a hold period on first payouts to new accounts. Track bank detail changes and apply velocity limits. These measures catch the most common payout fraud patterns: account takeover, social engineering, and mistyped account numbers.
Payout fraud takes several predictable forms. Understanding the patterns helps you build defences against each one.
Account takeover. A fraudster gains access to a legitimate user's account (through phishing, credential stuffing, or a data breach). They change the payout bank details to their own account and request a withdrawal. The money goes to the fraudster's bank account.
Social engineering. A fraudster contacts your support team pretending to be a user. They convince the agent to change the payout bank details. The next scheduled payout goes to the fraudster.
Insider fraud. Someone with admin access to your system changes payout details for multiple users, redirecting payouts to accounts they control.
Mistyped account numbers. Not fraud, but just as costly. A user mistypes one digit. The money goes to a stranger's account. Recovery depends on the stranger's goodwill.
Account resolution catches all four patterns by confirming the destination account exists and revealing who owns it before money moves.
The simplest and most effective rule: never create a transfer recipient without first resolving the account.
// secure-payout.js
const axios = require('axios');
async function secureCreateRecipient(userId, accountNumber, bankCode) {
// Step 1: Resolve - mandatory, no exceptions
var resolved;
try {
var response = await axios.get(
'https://api.paystack.co/bank/resolve',
{
params: { account_number: accountNumber, bank_code: bankCode },
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY },
timeout: 15000,
}
);
resolved = response.data.data;
} catch (err) {
// If resolution fails for any reason, stop
return { success: false, reason: 'Could not verify account. Try again later.' };
}
// Step 2: Get user profile for comparison
var user = await db.query(
'SELECT first_name, last_name FROM users WHERE id = $1',
[userId]
);
var userName = user.rows[0].first_name + ' ' + user.rows[0].last_name;
// Step 3: Compare names
var nameMatch = compareNames(userName, resolved.account_name);
// Step 4: Log everything
await logResolution(userId, accountNumber, bankCode, resolved.account_name, nameMatch);
return {
success: true,
resolved_name: resolved.account_name,
name_match: nameMatch,
requires_confirmation: !nameMatch.passes,
};
}
No bypass for anyone. Do not allow admin overrides that skip resolution. Do not create "fast path" payout routes that skip verification. Every payout, every time, goes through resolution. If the resolution endpoint is down, payouts wait.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
When the resolved account name does not match the user's profile name, you have several options depending on the severity of the mismatch.
Close match (first and last name present, different order or with extras): Auto-approve but show the resolved name to the user for confirmation. Log the confirmation.
Partial match (only one name component matches): Flag for manual review. Ask the user to explain the discrepancy. Common legitimate reasons: married name on one record, maiden name on another. Legal name vs. preferred name.
No match (completely different names): Block the payout. This is a strong indicator that the bank account belongs to someone else. Either the user entered the wrong account or someone is trying to redirect funds.
// mismatch-action.js
function decideMismatchAction(matchResult) {
if (matchResult.score >= 0.6) {
return {
action: 'approve_with_confirmation',
message: 'Please confirm this is your account.',
};
}
if (matchResult.score >= 0.3) {
return {
action: 'manual_review',
message: 'The account name differs from your profile. Our team will review this shortly.',
};
}
return {
action: 'block',
message: 'The account name does not match your profile. Please check your bank details.',
};
}
For the details of name comparison implementation, see the fuzzy comparison guide.
One of the strongest fraud signals is a change to bank details shortly before a payout request. Track every change and apply rules.
// track-changes.js
async function trackBankDetailChange(userId, oldAccount, newAccount) {
// Log the change
await db.query(
'INSERT INTO bank_detail_changes ' +
'(user_id, old_account_number, old_bank_code, new_account_number, new_bank_code, changed_at) ' +
'VALUES ($1, $2, $3, $4, $5, NOW())',
[userId, oldAccount.number, oldAccount.bankCode, newAccount.number, newAccount.bankCode]
);
// Check change frequency
var recentChanges = await db.query(
'SELECT COUNT(*) as count FROM bank_detail_changes ' +
'WHERE user_id = $1 AND changed_at > NOW() - INTERVAL '30 days'',
[userId]
);
var changeCount = parseInt(recentChanges.rows[0].count);
if (changeCount >= 3) {
// Flag account for review
await flagAccountForReview(userId, 'Frequent bank detail changes: ' + changeCount + ' in 30 days');
return { blocked: true, reason: 'Too many bank detail changes. Account under review.' };
}
// Enforce cooldown after change
return {
blocked: false,
cooldown_hours: 24,
message: 'Bank details updated. Payouts to this account available after 24 hours.',
};
}
24-hour cooldown after every change. When a user changes their bank details, delay payouts to the new account for 24 hours. This gives the legitimate user time to notice if their account was compromised and their bank details changed by an attacker.
Notify on change. Send an email and SMS to the user whenever bank details are changed. If the legitimate user did not make the change, they can act immediately.
The first payout to any new bank account should be held for review, regardless of match quality.
Why this works: Fraudsters need speed. They want the money out before anyone notices the account takeover. A 24-hour hold gives the real account owner time to log in, notice something wrong, and alert you. It also gives your fraud team time to review if any flags were raised.
// first-payout-hold.js
async function shouldHoldPayout(userId, recipientCode) {
// Check if we have ever successfully sent money to this recipient
var previousPayouts = await db.query(
'SELECT COUNT(*) as count FROM payouts ' +
'WHERE user_id = $1 AND recipient_code = $2 AND status = $3',
[userId, recipientCode, 'success']
);
if (parseInt(previousPayouts.rows[0].count) === 0) {
return {
hold: true,
hold_until: new Date(Date.now() + 24 * 60 * 60 * 1000),
reason: 'First payout to new account - standard security hold',
};
}
return { hold: false };
}
Communicate clearly. Tell the user: "For your security, first payouts to new bank accounts are reviewed before processing. Your payout will be sent within 24 hours." Users who understand the reason accept the delay.
Skip for trusted users. If a user has Tier 3 KYC verification (BVN) and a long history of clean transactions, consider waiving the first-payout hold. Balance security with user experience.
Protect against internal threats with access controls and audit logging.
No single person should be able to change bank details and approve payouts. Separate the roles. One person handles user support (can view details). Another person approves flagged payouts. No one can do both.
Log all admin actions. Every time an admin views, edits, or approves a payout or bank detail change, log who did it, when, and from what IP address.
Require dual approval for large payouts. Payouts above a threshold you define should require approval from two different admin accounts. This prevents a single compromised or malicious admin from stealing funds.
Alert on anomalies. If an admin changes bank details for multiple users in a short period, trigger an alert. Normal support operations do not involve bulk bank detail changes.
For more on access controls, see the role-based access guide.
Here is the full sequence, combining all the defences discussed:
This flow adds a few seconds for the user and a 24-hour delay on first payouts. In return, it prevents the most common and costly payout fraud patterns.
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