Accepting M-Pesa Payments with the Paystack Charge API
The Paystack Charge API lets you accept M-Pesa payments without redirecting to Paystack Checkout. You POST to /charge with the customer email, amount in cents, currency KES, and a mobile_money object containing the phone number and provider "mpesa." Paystack triggers the STK push and returns a "pay_offline" status. You wait for the charge.success webhook, then verify and fulfill the order.
Why Use the Charge API for M-Pesa
Paystack gives you two ways to accept payments. The standard way is Initialize Transaction: you create a transaction, get a checkout URL, and redirect the customer to Paystack's hosted checkout page. The customer selects their payment method, enters their details, and completes the payment on Paystack's page.
The Charge API skips all of that. You collect the customer's phone number in your own form, send it to Paystack's /charge endpoint, and Paystack triggers the STK push directly. No redirect. No popup. No Paystack checkout page. The customer stays on your site the whole time.
When does this matter?
- You want a seamless experience. The customer clicks "Pay with M-Pesa" on your page, their phone buzzes with the STK push, they enter their PIN, and your page updates to "Payment confirmed." No context switching, no new pages loading, no Paystack branding in the middle of your flow.
- You already have the phone number. If the customer entered their phone during registration, during a previous checkout step, or it is stored in their profile, you do not want them to re-enter it on a Paystack page.
- M-Pesa is your only payment method. If every customer pays with M-Pesa and you do not need card or Pesalink options, the Paystack Checkout page adds unnecessary steps. The Charge API gives you the shortest possible path.
- You need full control over the waiting UI. With Paystack Checkout, the "waiting for payment" screen is Paystack's. With the Charge API, you build your own. Show your brand, your messaging, your retry logic.
If you need multi-method checkout (M-Pesa, cards, Pesalink in one flow), the Initialize Transaction / Checkout approach is simpler because Paystack handles the method selection UI. The Charge API is for when you want to control the experience yourself.
The Charge Flow: Step by Step
The Charge API flow for M-Pesa has four stages. Understanding each one prevents confusion about what is happening and when.
Stage 1: Initiate the charge.
Your backend sends a POST request to https://api.paystack.co/charge with the customer's email, the amount in cents, the currency (KES), and a mobile_money object containing the phone number and provider ("mpesa").
Stage 2: Paystack triggers the STK push.
Paystack receives your request, communicates with Safaricom, and an STK push is sent to the customer's phone. The API response comes back with status: "pay_offline" (or a similar pending status). This means: "We've sent the prompt to the phone. We're waiting for the customer to enter their PIN."
Stage 3: The customer completes (or does not complete) the payment.
On their phone, the customer sees the M-Pesa prompt with the amount and Paystack's shortcode. They enter their PIN. The payment processes through Safaricom. If they do not respond in time, the request times out.
Stage 4: Paystack notifies you.
When the payment succeeds, Paystack sends a charge.success webhook to your server. If it fails (timeout, insufficient funds, wrong PIN), you receive a failure indication. You verify the transaction via the Verify endpoint and update your database.
The critical point: the HTTP response from the /charge endpoint does not tell you whether the payment succeeded. It tells you that the STK push was initiated. The actual result comes asynchronously via the webhook. Your code must be designed for this asynchronous pattern.
Code: Initiating the M-Pesa Charge
Here is the backend code to initiate an M-Pesa charge through the Paystack Charge API.
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const PAYSTACK_SECRET_KEY = process.env.PAYSTACK_SECRET_KEY;
// Initiate an M-Pesa charge
app.post('/api/charge/mpesa', async (req, res) => {
const { email, phone, amountKES, orderId } = req.body;
// Validate inputs
if (!email || !phone || !amountKES || !orderId) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Validate phone format (basic check)
const cleanPhone = phone.replace(/s/g, '');
if (!/^(07|01|2547|2541)d{8}$/.test(cleanPhone)) {
return res.status(400).json({ error: 'Invalid Kenyan phone number' });
}
const reference = `CHG-${orderId}-${Date.now()}`;
try {
const response = await fetch('https://api.paystack.co/charge', {
method: 'POST',
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
amount: amountKES * 100, // KES to cents
currency: 'KES',
reference,
mobile_money: {
phone: cleanPhone,
provider: 'mpesa',
},
metadata: {
order_id: orderId,
},
}),
});
const data = await response.json();
if (!data.status) {
return res.status(400).json({
error: data.message || 'Charge initiation failed',
});
}
// Expected: data.data.status === 'pay_offline'
// This means the STK push has been sent to the customer's phone
// Save the reference and status to your database
// await saveChargeRecord(orderId, reference, data.data.status);
res.json({
status: data.data.status,
reference: data.data.reference,
display_text: data.data.display_text || 'Check your phone for the M-Pesa prompt',
});
} catch (err) {
console.error('Charge initiation error:', err);
res.status(500).json({ error: 'Failed to initiate payment' });
}
});
The response from the /charge endpoint includes:
data.status: Usually"pay_offline"for M-Pesa. This means Paystack has initiated the STK push and is waiting for the customer.data.reference: The transaction reference. Use this for verification later.data.display_text: A message you can show to the customer (e.g., "Please check your phone to complete payment").
Note on the phone number: Paystack accepts the local format (07XXXXXXXX) and the international format (2547XXXXXXXX). Clean and validate the phone number before sending it. A badly formatted number will cause the charge to fail.
Building the Waiting UI
After you initiate the charge, the customer is waiting on your page. Their phone has (or will soon have) an STK push. You need to tell them what is happening and what to do.
Your frontend should show a "waiting for payment" state. Here is what to include:
- A clear instruction: "Check your phone for the M-Pesa prompt. Enter your PIN to complete the payment."
- The amount: Remind them of the amount. "You are paying KES 1,500."
- A loading indicator: A spinner or progress animation so they know the page is not frozen.
- A timeout message: "If you do not see the prompt within 30 seconds, tap Retry." (The actual timeout depends on Safaricom's configuration, but giving the customer a time frame sets expectations.)
- A retry button: Allow the customer to resend the STK push if the first one did not arrive or timed out.
On the backend, you have two options for knowing when the payment completes:
Option A: Wait for the webhook. Your webhook handler receives charge.success and updates the order status. Your frontend polls a status endpoint on your server (e.g., GET /api/order/:orderId/status) every few seconds to check if the payment has been confirmed.
// Frontend: Poll for payment status
async function pollPaymentStatus(orderId, maxAttempts = 20) {
for (let i = 0; i < maxAttempts; i++) {
const res = await fetch(`/api/order/${orderId}/status`);
const data = await res.json();
if (data.status === 'paid') {
// Payment confirmed! Show success.
window.location.href = `/order-confirmation?id=${orderId}`;
return;
}
if (data.status === 'failed') {
// Payment failed. Show error with retry option.
showRetryUI('Payment failed. Please try again.');
return;
}
// Still waiting. Poll again after 3 seconds.
await new Promise(resolve => setTimeout(resolve, 3000));
}
// Max attempts reached. Show timeout message.
showRetryUI('We did not receive a confirmation. Please check your M-Pesa and try again if the payment was not completed.');
}
Option B: Poll the Verify endpoint directly. Instead of waiting for the webhook and polling your own server, poll Paystack's Verify Transaction endpoint from your backend. This is simpler but puts more load on Paystack's API.
// Backend: Check payment status by polling Paystack
app.get('/api/payment/status/:reference', async (req, res) => {
const { reference } = req.params;
const verifyRes = await fetch(
`https://api.paystack.co/transaction/verify/${reference}`,
{ headers: { Authorization: `Bearer ${PAYSTACK_SECRET_KEY}` } }
);
const data = await verifyRes.json();
res.json({
status: data.data?.status || 'pending',
amount: data.data?.amount,
currency: data.data?.currency,
});
});
Option A (webhook + internal polling) is better for production because it does not hit Paystack's API on every poll. Option B is simpler for prototypes and low-volume applications.
Handling the Webhook for Charge API Payments
The webhook handler for Charge API payments is identical to the one for Initialize Transaction payments. Paystack sends the same charge.success event regardless of which endpoint you used to create the charge.
app.post('/webhooks/paystack', async (req, res) => {
// Verify signature
const hash = crypto
.createHmac('sha512', PAYSTACK_SECRET_KEY)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.status(401).send('Invalid signature');
}
res.status(200).send('OK');
const { event, data } = req.body;
if (event === 'charge.success') {
const { reference, amount, currency, channel, metadata } = data;
// Verify the transaction
const verifyRes = await fetch(
`https://api.paystack.co/transaction/verify/${reference}`,
{ headers: { Authorization: `Bearer ${PAYSTACK_SECRET_KEY}` } }
);
const verification = await verifyRes.json();
if (verification.data.status !== 'success') {
console.error('Verification failed for', reference);
return;
}
// Check that the amount and currency match your expected values
const orderId = metadata?.order_id;
// const expectedAmount = await getExpectedAmount(orderId);
// if (verification.data.amount !== expectedAmount) { ... }
// Mark the order as paid (idempotently)
// await markOrderAsPaid(orderId, reference);
console.log(`Charge confirmed: ${reference}, ${amount / 100} ${currency}, channel: ${channel}`);
}
});
The channel field in the webhook data tells you which payment method was used. For M-Pesa payments initiated through the Charge API, this will be "mobile_money". You can use this to apply different post-payment logic for different channels if needed (e.g., different confirmation messages, different settlement expectations).
The same rules apply as with any Paystack webhook: verify the signature, return 200 immediately, verify the transaction via API, and handle idempotently. See the pillar guide for comprehensive webhook patterns.
How the Charge API Differs from Initialize Transaction
The two endpoints serve different purposes and have different characteristics. Here is a clear comparison.
| Aspect | Initialize Transaction | Charge API |
|---|---|---|
| Redirect needed? | Yes (to Paystack checkout) or popup | No. Entirely server-to-server. |
| Phone number collected by | Paystack checkout page | Your form (sent in the API request) |
| Payment method selection | Paystack checkout page | You decide before calling the API |
| Multiple channels in one flow | Yes (channels array) | No. You specify one provider per request. |
| Customer UI during payment | Paystack-hosted | Your own (you build the waiting screen) |
| Card payments | Yes | Yes (but requires different parameters: card number, CVV, etc.) |
| M-Pesa trigger | After customer enters phone on Paystack page | Immediately when your backend calls /charge |
| Webhook event | charge.success | charge.success (same event) |
| Verify endpoint | Same (/transaction/verify/:reference) | Same |
The key difference is who controls the UI. With Initialize Transaction, Paystack handles the checkout experience. With the Charge API, you do. Everything on the backend (webhooks, verification, settlement) works the same way regardless of which endpoint you used.
One important detail: the Charge API for card payments requires you to handle card data (number, expiry, CVV) which has PCI compliance implications. For M-Pesa, there is no sensitive data equivalent; the phone number is not classified as cardholder data. So using the Charge API for M-Pesa does not create PCI compliance concerns that do not already exist.
Handling Pending Status and Timeouts
After you call the Charge API, the response status is "pay_offline". This is a pending state. The payment has not succeeded or failed yet. The customer needs to act on their phone.
You need to handle three possible outcomes:
1. The customer completes the payment.
You receive a charge.success webhook. Your order is fulfilled. The polling on your frontend detects the status change and shows a success screen.
2. The customer cancels or the payment fails.
You receive a failure webhook (or the verify endpoint shows a failed status). Common failures: insufficient funds, wrong PIN entered too many times, or the customer tapped "Cancel" on the STK push. Show an error message and offer a retry.
3. The STK push times out.
The customer did not respond within the timeout window. The transaction stays in a pending/failed state. This is the most common issue in Kenya. The customer might have been away from their phone, might not have had network, or might have been confused by the unfamiliar business name on the STK push.
For timeouts, your frontend polling will eventually hit the max attempts. At that point, show a message like:
- "We did not receive a response from M-Pesa. This could mean the prompt expired."
- "Check your M-Pesa messages. If you were charged, your order will be confirmed automatically."
- "If you were not charged, tap Retry to send a new M-Pesa prompt."
On the retry, generate a new reference and initiate a new charge. Do not reuse the old reference for a new charge attempt. Each charge attempt should have its own unique reference to prevent confusion in reconciliation.
For network-specific resilience patterns, see Building for Kenyan Network Conditions.
Limitations of the Charge API for M-Pesa
The Charge API is powerful, but it has boundaries. Know them before you commit to this approach.
1. Paystack shortcode, not your paybill.
The STK push still shows Paystack's aggregator shortcode on the customer's phone. You cannot change this. If you need your own paybill number on the STK push, the Charge API does not help. You need direct Daraja integration.
2. No control over the timeout duration.
The STK push timeout is controlled by Safaricom (and Paystack's configuration with Safaricom). You cannot extend it or shorten it from your side. You can only handle the timeout gracefully in your UI.
3. No C2B validation.
The Charge API triggers an STK push (equivalent to Lipa na M-Pesa Online in Daraja terms). It does not support the C2B validation/confirmation flow where the customer initiates payment to your paybill and you validate the account number before accepting. If you need C2B, you need Daraja.
4. One provider per charge request.
Each Charge API call specifies one provider (e.g., "mpesa"). If the customer wants to switch to Airtel Money or a card, you need a separate charge initiation. With Initialize Transaction, the customer can switch methods on the Paystack checkout page without a new API call from your side.
5. Airtel Money requires the same pattern.
To accept Airtel Money through the Charge API, you use the same mobile_money object but with provider: "airtel" (check Paystack's current documentation for the exact provider string). The flow is the same: initiate charge, customer gets a USSD prompt, webhook on completion. If you want to support both M-Pesa and Airtel Money, you need to let the customer choose their provider in your form and route to the correct Charge API call.
6. Email is required.
The Charge API requires a customer email. In Kenya, not every customer has (or wants to share) an email address. For M-Pesa-only flows targeting mass market customers, this can be a friction point. You may need to use a placeholder email or collect the email as part of your signup flow. [TODO: verify if Paystack has relaxed the email requirement for mobile money charges in Kenya]
Decision Guide: Charge API vs Checkout vs Daraja
Three options. Here is when each one is the right call for M-Pesa in Kenya.
Paystack Checkout (Initialize Transaction):
- You want the fastest integration with the least code.
- You offer multiple payment methods and want Paystack to handle the selection UI.
- You do not need a custom checkout experience.
- The redirect or popup does not hurt your conversion.
Paystack Charge API:
- You want a seamless, no-redirect M-Pesa experience.
- M-Pesa is your primary or only payment method.
- You already have the customer's phone number.
- You want full control over the checkout UI and the waiting state.
- You are comfortable with Paystack's shortcode appearing on the STK push.
Direct Daraja STK Push:
- You need your own paybill number on the customer's phone.
- M-Pesa is your only payment method and you do not need a gateway.
- You need C2B validation, B2C payouts, or B2B transfers that Paystack does not support.
- Transaction volume is high enough that the fee difference between Daraja and Paystack matters.
The Charge API sits between the convenience of Paystack Checkout and the control of direct Daraja. It gives you more control than Checkout without the full complexity of a direct Safaricom integration. For many Kenyan M-Pesa-first products, it is the sweet spot.
For the full decision framework including cost, maintenance burden, and geographic expansion, see Paystack vs Daraja: When to Use a Gateway and When to Go Direct.
Learn the African Stack
The Charge API is how production Kenyan apps trigger M-Pesa payments without sending customers to a third-party page. It is the kind of integration detail that separates tutorial-level projects from shippable products.
McTaba's M-Pesa Integration micro-course (KES 9,999) covers both Paystack and Daraja integration patterns, with working code that handles STK push, webhooks, timeouts, and reconciliation. The 26-week bootcamp (KES 120,000) covers payment integration as part of building complete products that handle real money on real Kenyan networks.
Key Takeaways
- ✓The Charge API lets you trigger an M-Pesa STK push from your own backend without any redirect or popup. The customer stays on your page the entire time.
- ✓You POST to /charge with email, amount (cents), currency (KES), and mobile_money: { phone, provider: "mpesa" }. Paystack returns status "pay_offline" meaning the customer needs to complete on their phone.
- ✓After initiating the charge, you build your own "waiting for payment" UI. Show a message like "Check your phone for the M-Pesa prompt" while you wait for the webhook.
- ✓The charge.success webhook confirms the payment. Always verify the transaction via the Verify endpoint before granting value.
- ✓The Charge API is best for M-Pesa-only flows where you control the entire checkout experience. For multi-method checkout, Paystack Checkout (Initialize Transaction) is simpler.
- ✓Limitations: the STK push still shows Paystack's shortcode on the customer's phone, and you cannot control the timeout duration. Those are Safaricom and Paystack constraints.
Frequently Asked Questions
- Do I need to handle OTP or PIN flows with the Charge API for M-Pesa?
- No. For M-Pesa, the customer enters their PIN directly on the M-Pesa STK push prompt on their phone. There is no OTP step that you need to handle in your application. The PIN entry happens on the customer's device through Safaricom's interface, not through your app or Paystack. OTP and PIN handling in the Charge API is relevant for card payments, not mobile money.
- Can I use the Charge API for both M-Pesa and Airtel Money?
- Yes. Use the same /charge endpoint with mobile_money.provider set to "mpesa" for M-Pesa or the appropriate provider string for Airtel Money. Check Paystack's current documentation for the exact Airtel Money provider value. The flow is the same: initiate charge, customer authorizes on their phone, webhook confirms.
- What is the minimum transaction amount for M-Pesa through the Charge API?
- Paystack enforces minimum transaction amounts that may differ by payment method and currency. Check Paystack's documentation or try a test transaction to confirm the current minimum for KES mobile money charges. Remember that amounts are in cents, so KES 10 is sent as 1000.
- Can I retry a failed charge with the same reference?
- No. Each charge attempt should use a unique reference. If a charge fails or times out, generate a new reference for the retry. Reusing a reference for a new charge can cause conflicts in Paystack's system and in your reconciliation. Link the retry to the same order in your database, but give it a fresh reference.
- How do I know if the STK push was actually sent to the customer phone?
- When the Charge API returns successfully with status "pay_offline," it means Paystack has sent the STK push request to Safaricom. However, delivery to the customer's phone depends on the phone being on, having network, and Safaricom's processing. If the customer says they did not receive a prompt, it could be a network issue, an incorrect phone number, or a Safaricom delay. Check the phone number format, wait a few seconds, and try again if needed.
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