To send money to an M-Pesa wallet via Paystack, you create a transfer recipient with type mobile_money and bank_code MPESA, passing the phone number as the account_number. Then you initiate a transfer specifying that recipient, the amount in cents, and source as balance. Paystack processes the transfer and sends a webhook when it succeeds or fails.
Paystack Transfers move money out of your Paystack balance to external accounts. In Kenya, the most common destination is an M-Pesa wallet. You collect payments from customers through Paystack, and then you need to send some of that money out: refunds to customers, payouts to gig workers, commissions to agents, or disbursements to suppliers.
Without Transfers, you would need to withdraw your Paystack balance to your bank account, then send M-Pesa manually or through a separate B2C integration via Safaricom Daraja. That is slow, error-prone, and impossible to automate at scale.
With Paystack Transfers, the payout happens directly from your Paystack balance. One API call creates the recipient. Another API call sends the money. A webhook tells you when it lands. Your system can handle payouts to hundreds or thousands of recipients without anyone logging into an M-Pesa till.
This is the same capability that Daraja's B2C API provides, but wrapped in Paystack's interface. If you are already using Paystack to collect payments, Transfers keep everything in one system: collections, payouts, reconciliation, and reporting. If you need the full picture of how Paystack fits into the Kenya payment landscape, start with the Kenya pillar article.
Before you can send money, you register the destination as a transfer recipient. For M-Pesa wallets, the recipient type is mobile_money, the bank_code is MPESA, and the account_number is the phone number.
const createRecipient = async (name, phone) => {
const response = await fetch('https://api.paystack.co/transferrecipient', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_live_YOUR_SECRET_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'mobile_money',
name: name,
account_number: phone,
bank_code: 'MPESA',
currency: 'KES',
}),
});
const data = await response.json();
if (!data.status) {
throw new Error(data.message);
}
return data.data.recipient_code;
};
// Usage
const recipientCode = await createRecipient('Jane Wanjiku', '0712345678');
// Returns something like 'RCP_abc123xyz'
Key details on the recipient creation:
07XXXXXXXX or 01XXXXXXXX) or the international format (2547XXXXXXXX). Check Paystack's documentation for the exact format they currently accept, as this has varied.recipient_code. Store this in your database. You do not need to create a new recipient every time you want to send money to the same person.Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
With a recipient code in hand, you initiate the transfer. The money comes from your Paystack balance.
const initiateTransfer = async (recipientCode, amountInCents, reason, reference) => {
const response = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_live_YOUR_SECRET_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount: amountInCents,
recipient: recipientCode,
reason: reason,
reference: reference, // Your unique reference for idempotency
}),
});
const data = await response.json();
if (!data.status) {
throw new Error(data.message);
}
return {
transferCode: data.data.transfer_code,
status: data.data.status,
reference: data.data.reference,
};
};
// Send KES 500 to the recipient
const transfer = await initiateTransfer(
'RCP_abc123xyz',
50000, // KES 500 in cents
'Rider payout for July 20',
'payout_rider_042_20260720'
);
Points to understand about the initiation step:
50000. Same convention as the charge endpoints. Getting this wrong means sending 100x too much or 100x too little.pending or otp. If it is otp, you need to submit the OTP before the transfer proceeds.By default, Paystack requires an OTP (one-time password) before processing transfers. This is a security measure. When you initiate a transfer, instead of processing immediately, Paystack sends an OTP to your registered email or phone. You then submit that OTP via a separate API call to finalize the transfer.
// Finalize transfer with OTP
const finalizeTransfer = async (transferCode, otp) => {
const response = await fetch('https://api.paystack.co/transfer/finalize_transfer', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_live_YOUR_SECRET_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
transfer_code: transferCode,
otp: otp,
}),
});
return response.json();
};
This OTP step is fine if you are making occasional manual transfers. It is a problem if you are building an automated payout system that needs to process hundreds of transfers without human intervention.
To disable OTP for transfers:
Once OTP is disabled, the transfer will move directly to processing when you call the initiation endpoint. The status field in the response will be pending instead of otp.
If you are building for production, disable OTP. A system that requires someone to check their email and enter a code for every payout is not a system that scales. But make sure your server and API keys are secured. Without OTP, anyone with your secret key can drain your balance.
Transfers are asynchronous. The only reliable way to know whether a transfer succeeded is through webhooks. Paystack sends two events that matter:
transfer.success means the money reached the recipient's M-Pesa wallet.transfer.failed means something went wrong. The money goes back to your Paystack balance.const crypto = require('crypto');
app.post('/webhooks/paystack', express.json(), async (req, res) => {
// Verify webhook signature
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.status(401).send('Invalid signature');
}
const event = req.body;
switch (event.event) {
case 'transfer.success': {
const { reference, amount, recipient, transfer_code } = event.data;
// Mark the payout as completed in your database
await db.payouts.update({
where: { reference },
data: {
status: 'completed',
completedAt: new Date(),
transferCode: transfer_code,
},
});
console.log('Transfer succeeded:', reference);
break;
}
case 'transfer.failed': {
const { reference, amount, reason } = event.data;
// Mark the payout as failed, consider retry logic
await db.payouts.update({
where: { reference },
data: {
status: 'failed',
failureReason: reason,
failedAt: new Date(),
},
});
console.log('Transfer failed:', reference, reason);
break;
}
}
// Always respond 200 quickly so Paystack does not retry
res.status(200).send('OK');
});
Practical notes on transfer webhooks:
For a deeper look at webhook patterns across all Paystack events, see the Webhooks engineering guide.
There are two layers of limits to think about: Paystack's limits and Safaricom's M-Pesa limits.
Paystack limits:
M-Pesa limits:
For production systems, check your limits in the Paystack dashboard and build alerting around your balance. A ride-hailing app that runs out of Paystack balance at 6 PM on a Friday will have a lot of angry riders. Top up your balance proactively or set up automatic balance thresholds.
If you need to pay multiple recipients at once, Paystack supports bulk transfers. Instead of making individual API calls for each recipient, you send a single request with an array of transfers.
const bulkTransfer = async (transfers) => {
const response = await fetch('https://api.paystack.co/transfer/bulk', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_live_YOUR_SECRET_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
currency: 'KES',
source: 'balance',
transfers: transfers,
}),
});
return response.json();
};
// Pay three riders at once
await bulkTransfer([
{
amount: 150000, // KES 1,500
recipient: 'RCP_rider_001',
reason: 'Daily payout July 20',
reference: 'payout_rider_001_20260720',
},
{
amount: 230000, // KES 2,300
recipient: 'RCP_rider_002',
reason: 'Daily payout July 20',
reference: 'payout_rider_002_20260720',
},
{
amount: 87000, // KES 870
recipient: 'RCP_rider_003',
reason: 'Daily payout July 20',
reference: 'payout_rider_003_20260720',
},
]);
Bulk transfers simplify your code and reduce the number of API calls, but each transfer in the batch is still processed individually. You will receive separate webhooks for each one. Some may succeed while others fail. Your reconciliation logic needs to handle partial success within a batch.
For a real-world example of a bulk payout system, see Building a Boda-Boda Rider Payout System.
Your database says you initiated a transfer. Did it actually arrive? Webhooks are the primary mechanism, but you also need a fallback for cases where webhooks get lost, your server was down, or something went sideways.
Paystack provides two endpoints for checking transfer status:
// Verify a single transfer by reference
const verifyTransfer = async (reference) => {
const response = await fetch(
`https://api.paystack.co/transfer/verify/${reference}`,
{
headers: {
Authorization: 'Bearer sk_live_YOUR_SECRET_KEY',
},
}
);
return response.json();
};
// List recent transfers with pagination
const listTransfers = async (page = 1, perPage = 50) => {
const response = await fetch(
`https://api.paystack.co/transfer?page=${page}&perPage=${perPage}`,
{
headers: {
Authorization: 'Bearer sk_live_YOUR_SECRET_KEY',
},
}
);
return response.json();
};
A solid reconciliation pattern:
transfer.success or transfer.failed.This three-layer approach means a single point of failure (a missed webhook, a crashed cron job) does not leave you blind. For the broader reconciliation picture including incoming payments, see Reconciling M-Pesa Payments Received Through Paystack.
Transfers fail. Sometimes it is your fault, sometimes it is the network, sometimes it is Safaricom. Here are the common failure scenarios and how to deal with them.
transfer.reversed events.Build your error handling with the assumption that every transfer can fail. Log the failure reason from the webhook or verification response. Surface it to your operations team. If your system processes payouts in bulk, you need a dashboard or report that shows failed transfers so someone can investigate and retry them.
For general guidance on network-related error handling in the Kenyan context, see Building for Kenyan Network Conditions: Retries and Timeouts.
Both Paystack Transfers and Safaricom's Daraja B2C API send money to M-Pesa wallets. The choice depends on your setup.
Use Paystack Transfers when:
Use Daraja B2C when:
Some teams use both. They collect payments through Paystack and run high-volume payouts through Daraja B2C to save on fees. This adds reconciliation complexity, but it is a proven pattern. See Running Paystack and Daraja Side by Side for the practical details.
If you want to understand all the payment method options Paystack offers in Kenya beyond just M-Pesa transfers, the Kenya support overview covers the full picture.
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