McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Single Transfers with the Paystack API
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

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.

In this article

  1. 1. The Transfer Request
  2. 2. Designing References That Prevent Duplicates
  3. 3. Amount Formatting Across Currencies
  4. 4. Handling the OTP Flow
  5. 5. Tracking Transfer Status
  6. 6. Retry Logic for Failed Transfers
  7. 7. A Complete Single Payout Flow
  8. 8. Ship Payout Code That Works

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.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

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.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Transfers and Payouts: Complete Guide

Everything you need to send money out of your Paystack account: creating transfer recipients, initiating single and bulk transfers, handling OTP requirements, managing failures, and building automated payout systems for bank accounts, M-Pesa wallets, and mobile money across Africa.

Read more

Managing Transfer Failures and Reversals

Transfers fail. Sometimes they succeed and then reverse. This guide covers the full spectrum of things that go wrong with Paystack transfers: common failure reasons and how to classify them, building retry logic that does not make things worse, handling the rare but critical reversal case, and creating an operations playbook for your team.

Read more

Transfers to M-PESA Wallets, Paybills and Tills

Kenya runs on M-Pesa, and Paystack lets you send transfers to M-Pesa personal wallets, Paybill business accounts, and Till numbers. This guide covers creating recipients for each destination type, phone number formatting, account number requirements for Paybills, settlement timing, and the failure modes specific to M-Pesa transfers.

Read more

Building a Driver Payout System for a Ride Hailing App

Ride-hailing drivers need fast, reliable payouts. This guide covers building a driver payout system on Paystack: calculating trip-based earnings with commission deductions, daily settlement cycles, M-Pesa and bank payout options, handling cash-collected trips, real-time driver balance tracking, and instant withdrawal features.

Read more

Paystack Fees in Your Ledger: Gross vs Net Recording

When a customer pays NGN 50,000, Paystack takes a processing fee and settles the rest to your bank. How you record this in your ledger determines whether settlement reconciliation is straightforward or a nightmare. This guide covers the gross vs net recording decision and how to implement each approach.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.