Bonaventure OgetoBy Bonaventure Ogeto|

Single Transfers with the Paystack API

To initiate a single transfer on Paystack, POST to /transfer with source set to "balance", the amount in the smallest currency unit, a recipient_code, and a unique reference. If OTP is enabled, finalize with /transfer/finalize_transfer. Track the outcome through transfer.success and transfer.failed webhook events.

The Transfer Request

A single transfer sends money from your Paystack balance to exactly one recipient. You need three things before you can call the API: a funded balance, a recipient code (created via the transfer recipient endpoint), and a unique reference.

const initiateSingleTransfer = async (recipientCode, amountInMinorUnit, reason, reference) => {
  const response = 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: amountInMinorUnit,
      recipient: recipientCode,
      reason: reason,
      reference: reference,
    }),
  });

  const data = await response.json();

  if (!data.status) {
    throw new Error('Transfer failed: ' + data.message);
  }

  return {
    transferCode: data.data.transfer_code,
    status: data.data.status,
    reference: data.data.reference,
  };
};

The source field is always "balance". There is no other option. The amount is in the smallest currency unit: kobo for NGN (1 NGN = 100 kobo), pesewas for GHS (1 GHS = 100 pesewas), cents for KES (1 KES = 100 cents). If you want to send 5,000 Naira, pass 500000.

The reason field is optional but valuable. It shows up in the recipient's bank statement (though some banks truncate it) and in your Paystack dashboard. Use it for reconciliation. Something like "July vendor payout - Order #4521" tells you exactly what the transfer was for when you are debugging three months later.

Designing References That Prevent Duplicates

The reference field is the most important field in the entire request. It serves two purposes: it uniquely identifies the transfer in your system, and it prevents duplicate payments on Paystack's side. If you send two transfer requests with the same reference, Paystack treats the second one as a duplicate and returns the existing transfer.

Good reference patterns are deterministic. Given the same inputs, you generate the same reference.

// Pattern 1: Payout record ID (simplest and safest)
function makeReference(payoutId) {
  return 'payout_' + payoutId;
}
// e.g., payout_8847

// Pattern 2: Entity + period (prevents paying the same person twice for the same period)
function makeVendorReference(vendorId, period) {
  return 'vendor_' + vendorId + '_' + period;
}
// e.g., vendor_42_2026-07

// Pattern 3: Order-based (for order-triggered payouts)
function makeOrderReference(orderId) {
  return 'order_payout_' + orderId;
}
// e.g., order_payout_10542

Avoid references that include random components like timestamps or UUIDs unless they come from a database record you created first. If your reference is "payout_" + Date.now() and the first request fails silently (network timeout, no response), your retry generates a different reference and you send the money twice.

The safest pattern: create a payout record in your database first, get its auto-incremented ID, use that ID as the reference, then call Paystack. If anything goes wrong and you retry, the same database record produces the same reference. For a deep dive into idempotency patterns, see preventing duplicate payouts.

Amount Formatting Across Currencies

Getting the amount wrong is one of the most dangerous bugs in payment code. If your code confuses Naira with kobo, you send 100x the intended amount. There is no undo for a successful transfer.

The rules are simple but need discipline:

  • NGN: 1 Naira = 100 kobo. To send 5,000 NGN, pass 500000.
  • GHS: 1 Cedi = 100 pesewas. To send 500 GHS, pass 50000.
  • KES: 1 Shilling = 100 cents. To send 10,000 KES, pass 1000000.
  • ZAR: 1 Rand = 100 cents. To send 1,000 ZAR, pass 100000.
// Conversion helper
function toMinorUnit(amount, currency) {
  var multipliers = {
    NGN: 100,
    GHS: 100,
    KES: 100,
    ZAR: 100,
  };

  var multiplier = multipliers[currency];
  if (!multiplier) {
    throw new Error('Unsupported currency: ' + currency);
  }

  return Math.round(amount * multiplier);
}

// Usage
var amountInKobo = toMinorUnit(5000, 'NGN'); // 500000
var amountInCents = toMinorUnit(10000, 'KES'); // 1000000

Use Math.round rather than simple multiplication to avoid floating-point issues. In JavaScript, 19.99 * 100 gives 1998.9999999999998, not 1999. Rounding fixes this. Better yet, store and work with amounts in the minor unit throughout your application and only convert to the major unit for display.

Handling the OTP Flow

If OTP is enabled on your Paystack account (which is the default), the transfer response comes back with status: "otp". Paystack sends a one-time password to the phone number or email registered on your Paystack business account. You must submit it to finalize the transfer.

const finalizeTransfer = async (transferCode, otp) => {
  const response = await fetch('https://api.paystack.co/transfer/finalize_transfer', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      transfer_code: transferCode,
      otp: otp,
    }),
  });

  const data = await response.json();
  return data;
};

The OTP flow is designed for scenarios where a human is initiating the transfer from a dashboard or admin panel. They click "send payout", Paystack sends them the OTP, they type it in, your code calls finalize, and the transfer proceeds.

For automated systems (cron-based payouts, webhook-triggered settlements), OTP is impractical because no human is present to enter the code. You can disable OTP through the dashboard or API. See transfer OTP: enabling, disabling, and automating for the full process and security implications.

If you leave OTP enabled but do not submit the code, the transfer sits in the otp state until it expires. The money remains reserved in your balance during this time. Expired OTP transfers eventually release the funds back to your available balance.

Tracking Transfer Status

After you initiate a transfer, it moves through statuses: otp (if OTP is enabled), pending (processing through the banking network), and then either success, failed, or (rarely) reversed.

The correct way to track status is through webhooks, not polling. Paystack pushes status changes to your webhook URL as they happen.

// Webhook handler for single transfer outcomes
app.post('/webhooks/paystack', async (req, res) => {
  // Always respond 200 immediately
  res.sendStatus(200);

  var event = req.body;
  var reference = event.data.reference;

  // Find the payout record in your database
  var payout = await db.payouts.findByReference(reference);
  if (!payout) {
    console.error('Unknown reference: ' + reference);
    return;
  }

  if (event.event === 'transfer.success') {
    await db.payouts.update(payout.id, {
      status: 'completed',
      completedAt: new Date(event.data.updated_at),
    });
    // Notify the recipient if needed
    await notifyRecipient(payout.recipientId, 'Your payout has been sent.');
  }

  if (event.event === 'transfer.failed') {
    await db.payouts.update(payout.id, {
      status: 'failed',
      failureReason: event.data.reason,
      failedAt: new Date(event.data.updated_at),
    });
    // Decide whether to retry based on the reason
    await evaluateRetry(payout, event.data.reason);
  }

  if (event.event === 'transfer.reversed') {
    await db.payouts.update(payout.id, {
      status: 'reversed',
      reversedAt: new Date(event.data.updated_at),
    });
    // Alert operations team
    await alertOps('Transfer reversed: ' + reference);
  }
});

If you need to check a transfer status manually (for debugging or reconciliation), use the fetch endpoint:

const getTransferStatus = async (transferIdOrCode) => {
  var response = await fetch(
    'https://api.paystack.co/transfer/' + transferIdOrCode,
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  );

  var data = await response.json();
  return data.data.status;
};

Use this for debugging, not as your primary status tracking. Polling the API in a loop wastes rate limit quota and is slower than webhooks.

Retry Logic for Failed Transfers

Not all failures are equal. Some are transient (the bank was down for ten minutes) and resolve on retry. Others are permanent (the account does not exist) and will fail every time. Your retry logic must distinguish between them.

// Classify failure reasons
function isRetryable(failureReason) {
  var transientPatterns = [
    'timeout',
    'temporarily unavailable',
    'network',
    'service unavailable',
    'try again',
    'system error',
  ];

  var lowerReason = (failureReason || '').toLowerCase();
  for (var i = 0; i < transientPatterns.length; i++) {
    if (lowerReason.indexOf(transientPatterns[i]) !== -1) {
      return true;
    }
  }
  return false;
}

// Retry handler
async function evaluateRetry(payout, failureReason) {
  if (!isRetryable(failureReason)) {
    // Permanent failure, do not retry
    await db.payouts.update(payout.id, {
      requiresManualReview: true,
      reviewNote: 'Permanent failure: ' + failureReason,
    });
    return;
  }

  // Check retry count
  if (payout.retryCount >= 3) {
    await db.payouts.update(payout.id, {
      requiresManualReview: true,
      reviewNote: 'Max retries exceeded. Last reason: ' + failureReason,
    });
    return;
  }

  // Schedule retry with exponential backoff
  var delayMinutes = Math.pow(2, payout.retryCount) * 15; // 15, 30, 60 min
  await db.payouts.update(payout.id, {
    status: 'retry_scheduled',
    retryCount: payout.retryCount + 1,
    retryAt: new Date(Date.now() + delayMinutes * 60 * 1000),
  });
}

The key principle: never retry blindly. Check the reason, cap the retry count, and add increasing delays between attempts. Bank downtime issues usually resolve within minutes to hours, so three retries with exponential backoff (15 minutes, 30 minutes, 60 minutes) covers most transient failures without being aggressive.

For detailed patterns on handling failures and the rare reversal case, see managing transfer failures and reversals.

A Complete Single Payout Flow

Putting it all together, here is the full flow from payout request to completion.

async function processPayout(payoutId) {
  // Step 1: Load the payout record
  var payout = await db.payouts.findById(payoutId);
  if (payout.status !== 'approved') {
    throw new Error('Payout is not in approved state');
  }

  // Step 2: Check balance
  var balanceResponse = await fetch('https://api.paystack.co/balance', {
    headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY },
  });
  var balances = (await balanceResponse.json()).data;
  var available = balances.find(function(b) { return b.currency === payout.currency; });

  if (!available || available.balance < payout.amountMinorUnit) {
    await db.payouts.update(payoutId, {
      status: 'insufficient_balance',
      note: 'Available: ' + (available ? available.balance : 0),
    });
    return;
  }

  // Step 3: Build reference from payout ID
  var reference = 'payout_' + payout.id;

  // Step 4: Initiate the transfer
  try {
    var result = await initiateSingleTransfer(
      payout.recipientCode,
      payout.amountMinorUnit,
      payout.reason || 'Payout',
      reference
    );

    // Step 5: Update payout record to processing
    await db.payouts.update(payoutId, {
      status: 'processing',
      transferCode: result.transferCode,
      paystackStatus: result.status,
      initiatedAt: new Date(),
    });
  } catch (err) {
    await db.payouts.update(payoutId, {
      status: 'initiation_failed',
      note: err.message,
    });
  }

  // Step 6: Wait for webhook (handled by the webhook endpoint)
}

This flow covers the happy path and the most common failure mode (insufficient balance). The webhook handler covers the rest. Your payout lifecycle should look like: created -> approved -> processing -> completed (or failed, reversed).

Ship Payout Code That Works

Single transfers are the building block. Get them right, and everything else (bulk transfers, automated payouts, vendor portals) builds on a solid foundation.

The McTaba bootcamp covers payment integration end to end. You write the code, test it against Paystack's test environment, and ship it.

Create a free McTaba account

Key Takeaways

  • A single transfer sends money from your Paystack balance to one recipient. The API call requires source ("balance"), amount in smallest currency unit, a recipient_code, and a unique reference.
  • Amounts are always in the smallest currency unit: kobo for NGN, pesewas for GHS, cents for KES. Sending 5,000 Naira means passing 500000.
  • The reference field is your primary defense against duplicate payments. Use deterministic references derived from your payout record ID or a combination of recipient and period.
  • If OTP is enabled, the transfer status returns as "otp" and you must call /transfer/finalize_transfer with the code. If OTP is disabled, the transfer goes straight to "pending".
  • Never mark a payout as complete based on the API response. The API acceptance only means Paystack received your request. Final confirmation comes through webhooks.
  • Build retry logic that checks the failure reason. Retry for transient errors like bank downtime. Do not retry for permanent errors like invalid accounts.

Frequently Asked Questions

What is the minimum and maximum transfer amount?
Paystack enforces minimum and maximum transfer amounts that vary by currency, destination type, and your account tier. These limits can change over time. Check your Paystack dashboard or contact Paystack support for the current limits on your account.
Can I include metadata in a transfer request?
The transfer endpoint does not support a metadata field the way the transaction initialization endpoint does. Use the reason field for human-readable context and the reference field for linking back to your internal records. Store any additional metadata in your own database tied to the reference.
What happens if my server crashes after initiating a transfer but before saving the transfer code?
Use your deterministic reference to recover. When your server comes back, you can look up the transfer on Paystack using the reference you would have sent. If the transfer exists on Paystack, update your database. If it does not, you can safely re-initiate with the same reference.
How do transfer fees get deducted?
Paystack deducts transfer fees from your balance on top of the transfer amount. If you send 5,000 NGN to a recipient, the total deducted from your balance is 5,000 NGN plus the applicable transfer fee. Factor fees into your balance checks before initiating payouts.

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