Paystack prevents duplicate transfers through the reference field. If you send two transfer requests with the same reference, Paystack returns the existing transfer instead of creating a new one. Design deterministic references (derived from payout record IDs or entity-period combinations) and add database-level unique constraints to catch duplicates before they reach Paystack.
When your system sends a payout twice, you have paid someone double. Unlike a failed transfer (where Paystack refunds you automatically), a successful duplicate goes through. The money is in the recipient's bank account. Getting it back requires the recipient's cooperation, which you may or may not get.
Duplicate payouts happen because of:
Every one of these scenarios is preventable with proper idempotency design.
When you include a reference in your transfer request, Paystack checks if a transfer with that reference already exists. If it does, Paystack returns the existing transfer instead of creating a new one. No duplicate payment.
// First call: creates the transfer
var response1 = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount: 500000,
recipient: 'RCP_abc123',
reference: 'payout_vendor42_july',
}),
});
// Second call with same reference: returns the existing transfer
var response2 = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount: 500000,
recipient: 'RCP_abc123',
reference: 'payout_vendor42_july', // Same reference
}),
});
// response2 returns the same transfer, not a new one
// No duplicate payment
This only works if you always provide a reference. If you omit the reference, Paystack generates a random one for each request, and there is no deduplication. Always provide your own reference.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
A good reference is deterministic: given the same payout inputs, it always produces the same string. Here are patterns ranked by reliability.
// Pattern 1: Payout record ID (best)
// Create the payout record first, use its ID
function referenceFromPayoutId(payoutId) {
return 'payout_' + payoutId;
}
// payout_8847
// Pattern 2: Entity + period (good for scheduled payouts)
function referenceFromVendorPeriod(vendorId, period) {
return 'vendor_' + vendorId + '_' + period;
}
// vendor_42_2026-07-w3
// Pattern 3: Order-based (good for order-triggered payouts)
function referenceFromOrder(orderId, vendorId) {
return 'order_' + orderId + '_vendor_' + vendorId;
}
// order_10542_vendor_42
// Pattern 4: Hash-based (for complex inputs)
var crypto = require('crypto');
function referenceFromHash(vendorId, amount, period) {
var input = vendorId + '_' + amount + '_' + period;
var hash = crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
return 'pay_' + hash;
}
// pay_a1b2c3d4e5f6g7h8
Avoid these patterns:
// BAD: Random component means retries create different references
var badRef1 = 'payout_' + Date.now(); // Different every millisecond
var badRef2 = 'payout_' + Math.random(); // Different every call
var badRef3 = 'payout_' + crypto.randomUUID(); // Different every call
Pattern 1 (payout record ID) is the safest. Your database guarantees the ID is unique, and since you create the record before calling Paystack, any retry uses the same ID and therefore the same reference. The record exists as your source of truth regardless of what happens with the API call.
Paystack deduplicates at the API level, but you should also prevent duplicates at the database level. If your code creates two payout records for the same payment, they will have different IDs and different references, and Paystack will treat them as separate transfers.
// Add a unique constraint on the reference column
// SQL: ALTER TABLE payouts ADD CONSTRAINT unique_reference UNIQUE (reference);
// For vendor-period deduplication, add a compound unique constraint
// SQL: ALTER TABLE payouts ADD CONSTRAINT unique_vendor_period
// UNIQUE (vendor_id, payout_period);
// Application-level check before creating a payout record
async function createPayoutSafely(vendorId, period, amount) {
// Check if a payout already exists for this vendor and period
var existing = await db.payouts.findByVendorAndPeriod(vendorId, period);
if (existing) {
console.log(
'Payout already exists for vendor ' + vendorId
+ ' period ' + period + ': ' + existing.reference
);
return existing; // Return the existing payout
}
// Create the payout record
try {
var payout = await db.payouts.create({
vendorId: vendorId,
payoutPeriod: period,
amountMinorUnit: amount,
reference: 'vendor_' + vendorId + '_' + period,
status: 'pending_approval',
});
return payout;
} catch (err) {
// Unique constraint violation means a concurrent request already created it
if (err.code === '23505' || err.message.indexOf('unique') !== -1) {
return await db.payouts.findByVendorAndPeriod(vendorId, period);
}
throw err;
}
}
The try-catch on the unique constraint handles the race condition where two concurrent requests both pass the "check if exists" step but only one can insert. The second one hits the constraint, catches the error, and returns the record the first one created.
Race conditions are the sneakiest source of duplicates. Two processes read the same payout record, both see it as "approved", both initiate a transfer. Here is how to prevent this.
// Use SELECT FOR UPDATE to lock the row during processing
async function processPayoutWithLock(payoutId) {
// Start a database transaction
var trx = await db.transaction();
try {
// Lock the row so no other process can read it until we are done
var payout = await trx.payouts.findByIdForUpdate(payoutId);
// Check status AFTER acquiring the lock
if (payout.status !== 'approved') {
// Another process already picked it up
await trx.rollback();
return;
}
// Mark as processing before calling Paystack
await trx.payouts.update(payoutId, { status: 'processing' });
await trx.commit();
// Now call Paystack (outside the transaction to avoid long locks)
var result = await initiateTransfer(
payout.recipientCode,
payout.amountMinorUnit,
payout.reason,
payout.reference
);
await db.payouts.update(payoutId, {
transferCode: result.transferCode,
initiatedAt: new Date(),
});
} catch (err) {
await trx.rollback();
throw err;
}
}
The key insight: change the status to processing inside the locked transaction, before calling Paystack. Any concurrent process that reads the same row will see processing instead of approved and skip it. Even if the Paystack call fails after the status change, the payout record shows processing, which your reconciliation job will catch and fix.
// Test 1: Retry simulation
async function testRetryIdempotency() {
var payoutId = await createTestPayout();
// Simulate processing the same payout twice
await processPayoutWithLock(payoutId);
await processPayoutWithLock(payoutId); // Should be a no-op
var payout = await db.payouts.findById(payoutId);
// Should have exactly one transfer code, not two
console.log('Status: ' + payout.status); // 'processing'
console.log('Test passed: retry did not create duplicate');
}
// Test 2: Concurrent request simulation
async function testConcurrentIdempotency() {
var payoutId = await createTestPayout();
// Simulate two concurrent processes
var results = await Promise.allSettled([
processPayoutWithLock(payoutId),
processPayoutWithLock(payoutId),
]);
// Exactly one should succeed, one should be a no-op
var succeeded = results.filter(function(r) { return r.status === 'fulfilled'; });
console.log('Succeeded: ' + succeeded.length); // Should be 2 (one processes, one skips)
// Check that only one transfer was initiated
var transfers = await db.transferLogs.findByPayoutId(payoutId);
console.log('Transfers initiated: ' + transfers.length); // Should be 1
}
// Test 3: Same reference to Paystack
async function testPaystackDeduplication() {
var reference = 'test_dedup_' + Date.now();
// Send the same transfer twice to Paystack
var result1 = await initiateTransfer('RCP_test', 10000, 'Test', reference);
var result2 = await initiateTransfer('RCP_test', 10000, 'Test', reference);
// Both should return the same transfer code
console.log('Same transfer code: '
+ (result1.transferCode === result2.transferCode));
}
Run these tests against Paystack's test environment, not live. The test environment lets you verify behavior without moving real money. Include these tests in your CI/CD pipeline if possible, or at minimum run them manually before deploying payout system changes.
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