Paystack Transfers to M-PESA Wallets Explained
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.
What Paystack Transfers Do and Why They Matter in Kenya
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.
Creating an M-Pesa Transfer Recipient
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:
- Phone number format. Use the local Kenyan format (
07XXXXXXXXor01XXXXXXXX) or the international format (2547XXXXXXXX). Check Paystack's documentation for the exact format they currently accept, as this has varied. - Name matters. The name you pass should match the registered M-Pesa name for that phone number. This is not always enforced, but mismatches can cause issues during compliance reviews.
- Recipient codes are reusable. Once you create a recipient, you get back a
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. - Duplicate handling. If you try to create a recipient with the same phone number and bank_code, Paystack may return the existing recipient instead of creating a duplicate. This is helpful, but do not rely on it. Track recipient codes on your side.
Initiating a Transfer to an M-Pesa Wallet
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:
- Amount is in cents. KES 500 is
50000. Same convention as the charge endpoints. Getting this wrong means sending 100x too much or 100x too little. - Source is always "balance". Transfers come from the funds sitting in your Paystack balance. If your balance is insufficient, the transfer will fail.
- The reference field is your idempotency key. If you send the same reference twice, Paystack will not process a duplicate transfer. Use this to prevent double payouts when retrying after timeouts.
- The response does not mean the money arrived. A successful HTTP response means Paystack accepted the transfer request. The actual M-Pesa disbursement happens asynchronously. You will get the final result via webhook.
- The status field in the response will typically be
pendingorotp. If it isotp, you need to submit the OTP before the transfer proceeds.
OTP Requirements and How to Disable Them
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:
- Log into your Paystack dashboard.
- Go to Settings, then Preferences (the exact navigation may change).
- Look for the transfer authorization setting. Disable OTP for transfers.
- Paystack may require additional verification or compliance steps before allowing you to disable OTP. This is normal. They want to make sure your account is secured through other means before removing the manual check.
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.
Handling Transfer Webhooks
Transfers are asynchronous. The only reliable way to know whether a transfer succeeded is through webhooks. Paystack sends two events that matter:
transfer.successmeans the money reached the recipient's M-Pesa wallet.transfer.failedmeans 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:
- Always verify the signature. The same rule that applies to charge webhooks applies here. If you skip this step, anyone can POST fake events to your endpoint and trick your system into marking payouts as complete.
- Use the reference to match. The reference in the webhook payload is the same reference you passed when initiating the transfer. Use it to find the corresponding record in your database.
- Respond quickly. Return a 200 status before doing heavy processing. If your endpoint takes too long, Paystack will time out and retry, which means you could process the same event multiple times.
- Handle duplicates. Paystack may retry webhook delivery if your server was briefly unreachable. Your handler must be idempotent. Check if you have already processed a given reference before updating your records again.
For a deeper look at webhook patterns across all Paystack events, see the Webhooks engineering guide.
Transfer Limits and Balance Requirements
There are two layers of limits to think about: Paystack's limits and Safaricom's M-Pesa limits.
Paystack limits:
- Your Paystack account has a daily and per-transaction transfer limit. These depend on your account tier and verification level. New accounts start with lower limits. As you complete KYC verification and build transaction history, Paystack increases your limits.
- You can only transfer what is in your Paystack balance. If your balance is KES 10,000 and you try to transfer KES 15,000, it will fail. Monitor your balance programmatically if you are building automated payouts.
- Transfer fees apply. Paystack deducts a fee per transfer from your balance. Check paystack.com/pricing for current rates.
M-Pesa limits:
- The recipient's M-Pesa wallet has a maximum balance (currently KES 300,000 for most individual accounts, but this can change). If your transfer would push them over the limit, it will fail on Safaricom's side.
- There are also per-transaction caps on M-Pesa. Amounts above the per-transaction limit will be rejected.
- These limits are set by Safaricom and can change without notice from Paystack. Your code should handle limit-related failures gracefully.
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.
Bulk Transfers for Multiple Payouts
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.
Reconciling Transfers
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:
- Primary: webhooks. Update your database when you receive
transfer.successortransfer.failed. - Secondary: scheduled polling. Run a cron job every 15 or 30 minutes that finds transfers in your database that are still in "pending" status beyond a reasonable window (say, 30 minutes). For each one, call the verify endpoint to check the actual status.
- Tertiary: daily reconciliation. Once a day, pull the full list of transfers from Paystack for that day and compare against your database. Flag any mismatches for manual review.
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.
Common Transfer Errors and How to Handle Them
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.
- Insufficient balance. Your Paystack balance does not have enough to cover the transfer plus fees. Solution: check your balance before initiating. Top up your balance from collections or manual deposits.
- Invalid recipient. The phone number is wrong, not registered on M-Pesa, or the account is suspended. Solution: validate phone numbers before creating recipients. Handle this error gracefully in your UI.
- Recipient wallet full. The M-Pesa wallet has hit its maximum balance. Your transfer will be rejected. Solution: inform the user and suggest they withdraw some funds before trying again.
- Network timeout. The connection to Safaricom timed out. This does not mean the transfer failed. It means you do not know the status. Solution: never retry blindly. Use the verify endpoint to check status first. Use idempotent references so retries do not create duplicates.
- Transfer reversed. In rare cases, a transfer that initially succeeded may be reversed. Monitor for
transfer.reversedevents.
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.
When to Use Paystack Transfers vs Daraja B2C
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:
- You are already collecting payments through Paystack and want to pay out from the same balance.
- You need to pay out to M-Pesa, bank accounts, and paybills from a single API.
- You want one dashboard for both collections and payouts.
- Your payout volume is moderate and you value simplicity over cost optimization.
Use Daraja B2C when:
- M-Pesa payouts are your primary operation and you need the lowest possible cost per transaction.
- You need the payout to come from your own paybill or till number for brand visibility.
- You already have a Daraja integration and Safaricom business account.
- You need features specific to Daraja B2C that Paystack does not expose.
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.
Key Takeaways
- ✓Paystack Transfers let you send money from your Paystack balance to any M-Pesa wallet in Kenya. You create a recipient once, then initiate transfers against that recipient as many times as needed.
- ✓The recipient type is mobile_money with bank_code set to MPESA and the phone number as the account_number. Currency must be KES.
- ✓Transfers are asynchronous. You initiate them via the API and receive the result through transfer.success or transfer.failed webhooks.
- ✓OTP verification may be required for transfers depending on your account settings. You can disable OTP for automated systems through the Paystack dashboard.
- ✓Transfer limits depend on your Paystack account tier and Safaricom M-Pesa limits. Confirm both before building automated payout systems.
- ✓Always reconcile transfers using webhooks and the List Transfers endpoint. Never assume a transfer succeeded just because the initiation returned a 200 response.
- ✓Failed transfers return your money to your Paystack balance. Build retry logic, but add safeguards against duplicate payouts.
Frequently Asked Questions
- How long does a Paystack transfer to M-Pesa take?
- Most transfers to M-Pesa wallets complete within a few minutes. However, the timing depends on Safaricom processing and network conditions. During peak hours or when Safaricom systems are under load, transfers can take longer. Do not promise "instant" to your users. Design your UI to show a pending state and update it when you receive the webhook.
- What happens to the money if a transfer fails?
- If a transfer fails, the money returns to your Paystack balance. You will receive a transfer.failed webhook with a reason. Common reasons include invalid phone numbers, full M-Pesa wallets, and Safaricom system issues. You can then retry the transfer or handle it manually.
- Can I send transfers from my test (sandbox) environment?
- Yes. Paystack provides a test environment where you can simulate transfers without sending real money. Use your test secret key (starts with sk_test_) and test phone numbers. The webhook events will fire as they would in production, so you can build and test your handling logic before going live.
- Is there a minimum transfer amount?
- Paystack has a minimum transfer amount that may vary by currency and destination type. Check the Paystack documentation or dashboard for the current minimum for KES mobile money transfers. Safaricom also has its own minimum transaction amount for M-Pesa, which applies regardless of how the transfer is initiated.
- Do I need a separate Safaricom business account to use Paystack Transfers?
- No. When you use Paystack Transfers, the M-Pesa disbursement goes through Paystack as the aggregator. You do not need your own Safaricom paybill or B2C credentials. Your relationship is with Paystack. They handle the Safaricom side.
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