Accepting M-Pesa Payments Through Paystack Checkout
To accept M-Pesa through Paystack Checkout, initialize a transaction with channels set to ["mobile_money"] and currency set to KES. Redirect the customer to the Paystack checkout URL (or use Inline JS). The customer selects M-Pesa, enters their phone number, and receives an STK push. Handle the charge.success webhook, verify the transaction server-side, and update your database.
How the Checkout Flow Works
Paystack Checkout is the fastest way to accept payments, including M-Pesa, without building a custom payment form. You initialize a transaction on your backend, and Paystack gives you a URL (redirect) or a JavaScript popup (Inline JS) where the customer completes the payment.
For M-Pesa in Kenya, the flow is:
- Your backend calls
POST /transaction/initializewith the amount in cents (KES), the customer's email, andchannels: ["mobile_money"]. - Paystack returns a checkout URL and an access code.
- Your frontend redirects the customer to that URL (redirect flow) or opens the Paystack Inline JS popup (inline flow).
- The customer sees the Paystack checkout page. If you specified
mobile_moneyas the only channel, the page skips the method selection and goes straight to the M-Pesa payment form. The customer enters their phone number. - Paystack sends an STK push to the customer's phone.
- The customer enters their M-Pesa PIN on their phone.
- Paystack processes the payment and sends a
charge.successwebhook to your server. - The customer is redirected back to your callback URL.
From your code's perspective, you do not interact with Safaricom at all. You talk to Paystack. Paystack talks to Safaricom. You receive the result via webhook.
If you want to skip the redirect entirely and trigger the STK push directly from your own form, use the Charge API instead. The Checkout approach is simpler to implement but adds the redirect or popup step.
Step 1: Initialize the Transaction
The Initialize Transaction endpoint creates a payment session. You specify the amount, currency, customer email, and which payment channels to offer.
// Express.js route to initialize a Paystack transaction for M-Pesa
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const PAYSTACK_SECRET_KEY = process.env.PAYSTACK_SECRET_KEY;
app.post('/api/pay', async (req, res) => {
const { email, amount, orderId } = req.body;
// Generate a unique reference for this transaction
const reference = `ORD-${orderId}-${Date.now()}`;
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
amount: amount * 100, // Convert KES to cents. KES 1,500 = 150000
currency: 'KES',
reference,
channels: ['mobile_money'], // Only show M-Pesa option
callback_url: 'https://yoursite.com/payment/callback',
metadata: {
order_id: orderId,
custom_fields: [
{
display_name: 'Order ID',
variable_name: 'order_id',
value: orderId,
},
],
},
}),
});
const data = await response.json();
if (!data.status) {
return res.status(400).json({ error: data.message });
}
// Save the reference and access_code to your database
// await saveTransaction(orderId, reference, data.data.access_code);
// Return the checkout URL to the frontend
res.json({
authorization_url: data.data.authorization_url,
access_code: data.data.access_code,
reference: data.data.reference,
});
});
Key details:
- Amount is in cents (kobo). KES 1,500 is sent as
150000. This is not a Kenyan convention; it is how Paystack handles all currencies. Multiply your KES amount by 100. - Currency must be KES. This tells Paystack to show Kenyan payment methods. Without it, M-Pesa will not appear.
- Channels: ["mobile_money"] restricts the checkout to M-Pesa and Airtel Money only. If you want the customer to choose between M-Pesa and cards, use
["mobile_money", "card"]. If you omit channels, Paystack shows all available methods. - The reference is your internal identifier. Make it unique. You will use it to verify the transaction later. The pattern above includes the order ID and a timestamp to guarantee uniqueness.
- callback_url is where Paystack redirects the customer after payment. This is your "thank you" or "order confirmation" page. It is not your webhook URL.
Step 2: What the Customer Sees
After you redirect the customer to the authorization_url, they land on the Paystack checkout page. Here is what happens on their end.
If you specified channels: ["mobile_money"]:
The checkout page skips payment method selection and goes directly to the mobile money form. The customer sees a phone number input field. They enter their M-Pesa number (07XXXXXXXX format). They tap "Pay." Paystack sends the STK push.
If you specified multiple channels (e.g., ["mobile_money", "card"]):
The customer sees a payment method selection screen. They choose "Mobile Money" or "Pay with M-Pesa." Then they enter their phone number. Then the STK push fires.
The STK push on the phone:
The customer's phone displays the standard M-Pesa payment prompt. It shows the amount, a Paystack-related business name, and asks for the M-Pesa PIN. The customer enters their PIN. The payment processes.
After payment:
The Paystack checkout page shows a "Payment successful" message. After a few seconds, the customer is redirected to your callback_url. Paystack appends the transaction reference to the URL as a query parameter: https://yoursite.com/payment/callback?reference=ORD-123-1721469600000.
If the payment fails or times out:
The checkout page shows an error message. The customer can try again (enter their phone number again) or close the page. If they close the page without completing payment, the transaction remains in "abandoned" status on Paystack's side.
For the inline popup flow (Paystack Inline JS), the experience is similar but the checkout appears as a modal overlay on your page instead of a separate page. The customer does not leave your site. This reduces drop-off but requires you to include the Paystack JavaScript SDK on your frontend.
Step 3: Handle the Webhook
The webhook is how Paystack tells your server that the payment succeeded (or failed). This is the authoritative signal. Do not rely on the redirect callback alone.
Why? Because the redirect can fail. The customer might close their browser before the redirect happens. Their internet might drop. They might navigate away. The webhook is sent server-to-server by Paystack and is not affected by the customer's browser behavior.
// Webhook handler for Paystack events
app.post('/webhooks/paystack', express.json(), async (req, res) => {
// Step 1: Verify the webhook signature
const hash = crypto
.createHmac('sha512', PAYSTACK_SECRET_KEY)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
console.error('Invalid Paystack webhook signature');
return res.status(401).send('Invalid signature');
}
// Step 2: Return 200 immediately, then process
res.status(200).send('OK');
const event = req.body;
if (event.event === 'charge.success') {
const { reference, amount, currency, channel } = event.data;
// Step 3: Verify the transaction via API (belt and suspenders)
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' ||
verification.data.amount !== amount ||
verification.data.currency !== currency
) {
console.error('Verification mismatch for', reference);
return;
}
// Step 4: Update your database
// await markOrderAsPaid(reference);
// await sendConfirmationEmail(verification.data.customer.email);
console.log('Payment confirmed:', reference, amount / 100, currency);
console.log('Payment channel:', channel); // 'mobile_money' for M-Pesa
}
});
Critical rules for webhook handling:
- Return 200 immediately. Paystack expects a quick response. If your server takes too long, Paystack may retry the webhook, and you could process the payment twice. Return 200 first, then do your processing. For production systems, queue the processing work using BullMQ or a similar job queue.
- Verify the signature. The
x-paystack-signatureheader is a SHA-512 HMAC of the request body, signed with your secret key. If the signature does not match, reject the request. This prevents someone from faking a webhook and tricking your server into granting value without an actual payment. - Verify the transaction via API. After signature verification, call the Verify Transaction endpoint with the reference. Confirm the status is "success," the amount matches your expected amount, and the currency is KES. This is the double-check. Some developers skip it. Do not.
- Handle idempotently. Paystack may send the same webhook more than once (retries). Your handler must be idempotent. If you receive
charge.successfor a reference you have already processed, skip it. Use the reference as your idempotency key.
For a deeper look at webhook engineering, see the pillar engineering guide.
Step 4: Handle the Redirect Callback
After payment, Paystack redirects the customer to your callback_url with the reference in the URL. This is your chance to show the customer a confirmation page.
// Callback handler (what the customer sees after payment)
app.get('/payment/callback', async (req, res) => {
const { reference } = req.query;
if (!reference) {
return res.status(400).send('Missing reference');
}
// Verify the transaction status
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') {
// Show the customer a confirmation page
res.send(`
<h1>Payment Successful</h1>
<p>Your payment of KES ${verification.data.amount / 100} has been confirmed.</p>
<p>Reference: ${reference}</p>
<a href="/orders">View your orders</a>
`);
} else {
// Payment failed or is still pending
res.send(`
<h1>Payment Not Confirmed</h1>
<p>Your payment could not be confirmed. Status: ${verification.data.status}</p>
<p>If you completed the M-Pesa payment, please wait a moment and refresh this page.</p>
<a href="/checkout">Try again</a>
`);
}
});
Important notes on the callback:
- The callback is for the customer's benefit, not yours. Use it to show a confirmation or error page. Your actual order fulfillment logic should be triggered by the webhook, not the callback. The customer might never reach the callback (browser closed, connection dropped), but the webhook will still arrive.
- Always verify on the callback too. Even though you verify in the webhook handler, also verify in the callback handler. This is because the webhook and the callback can arrive in either order. The callback might arrive before the webhook has been processed. Verifying in both places ensures the customer sees the correct status immediately.
- Handle the "pending" state. Sometimes the transaction is still processing when the customer hits the callback. The M-Pesa STK push might still be in progress. In this case, show a "waiting for confirmation" message and either poll the verify endpoint from the frontend or ask the customer to refresh the page.
Handling Timeouts
M-Pesa STK push has a timeout. If the customer does not enter their PIN within the timeout window, the payment fails. This is a common scenario in Kenya, and your integration needs to handle it gracefully.
Common reasons for timeouts:
- The customer's phone is off or has no signal.
- The customer was distracted and did not see the prompt in time.
- The STK push was delayed by Safaricom's processing (network congestion).
- The customer saw an unfamiliar business name (Paystack's shortcode) and hesitated.
What happens on the Paystack side: When the STK push times out, Paystack marks the transaction as failed. If the customer is on the Paystack checkout page, they see an error message with an option to try again. If you are using the Charge API, you receive a webhook or a polling result indicating the failure.
What you should do:
- On your callback page, if the transaction status is not "success," show a clear message: "The M-Pesa payment timed out. Please try again." Include a button or link to restart the payment process.
- Do not create a new order for the retry. Reuse the same order and generate a new Paystack reference. The customer should not end up with duplicate orders because the first payment attempt timed out.
- Consider adding instructions: "Make sure your phone is on and has network. You will receive an M-Pesa prompt. Enter your PIN within 30 seconds."
For timeout-specific engineering patterns, see M-Pesa STK Push Timeout Handling Compared Across Providers. For general reliability patterns on Kenyan networks, see Building for Kenyan Network Conditions.
Testing with Paystack Test Mode
Paystack provides a test mode that lets you simulate transactions without real money. This is essential for development and staging environments.
How to use test mode:
- Use your test secret key (starts with
sk_test_) instead of your live key. - All API calls work the same way. The endpoints are the same. The request/response format is the same.
- For M-Pesa in test mode, Paystack simulates the STK push. No actual push is sent to a real phone. The payment is automatically completed (or you can simulate failures using Paystack's test parameters).
Test mode behavior for M-Pesa:
- Paystack may auto-complete the M-Pesa payment in test mode after a short delay, simulating a successful payment without the actual STK push.
- The checkout page in test mode shows a "Test Mode" banner so you know you are not processing real money.
- Webhooks are sent in test mode just like in live mode. Your webhook handler will receive
charge.successevents for test transactions. This lets you test your entire flow end-to-end.
What to verify during testing:
- The transaction initializes correctly with the right amount and currency.
- The checkout page displays the M-Pesa option.
- Your webhook handler receives and correctly processes the
charge.successevent. - Your callback page shows the correct status.
- Your database is updated with the payment confirmation.
- Duplicate webhook deliveries do not cause double processing.
- Failed and timed-out transactions are handled gracefully in the UI.
Before going live, switch from sk_test_ to sk_live_. Update your webhook URL in the Paystack dashboard to point to your production server. Make one small real transaction to confirm everything works with actual M-Pesa.
Complete Express.js Example
Here is the full server code with all the pieces connected: initialization, webhook, callback, and verification.
const express = require('express');
const crypto = require('crypto');
const app = express();
const PAYSTACK_SECRET_KEY = process.env.PAYSTACK_SECRET_KEY;
// Parse JSON for all routes except the webhook
// (webhook needs raw body for signature verification in some setups)
app.use(express.json());
// ---- 1. Initialize Transaction ----
app.post('/api/pay', async (req, res) => {
const { email, amountKES, orderId } = req.body;
const reference = `ORD-${orderId}-${Date.now()}`;
try {
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
amount: amountKES * 100,
currency: 'KES',
reference,
channels: ['mobile_money'],
callback_url: `${process.env.BASE_URL}/payment/callback`,
metadata: { order_id: orderId },
}),
});
const data = await response.json();
if (!data.status) {
return res.status(400).json({ error: data.message });
}
// TODO: save reference to your orders table
res.json({ authorization_url: data.data.authorization_url, reference });
} catch (err) {
console.error('Initialize error:', err);
res.status(500).json({ error: 'Payment initialization failed' });
}
});
// ---- 2. Webhook Handler ----
app.post('/webhooks/paystack', async (req, res) => {
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'); // Respond immediately
const { event, data } = req.body;
if (event === 'charge.success') {
// Verify via API
try {
const verifyRes = await fetch(
`https://api.paystack.co/transaction/verify/${data.reference}`,
{ headers: { Authorization: `Bearer ${PAYSTACK_SECRET_KEY}` } }
);
const verification = await verifyRes.json();
if (verification.data.status === 'success') {
const orderId = verification.data.metadata?.order_id;
// TODO: mark order as paid (idempotently!)
// TODO: send confirmation SMS/email
console.log(`Order ${orderId} paid: KES ${verification.data.amount / 100}`);
}
} catch (err) {
console.error('Webhook processing error:', err);
// TODO: log for manual review or retry queue
}
}
});
// ---- 3. Redirect Callback ----
app.get('/payment/callback', async (req, res) => {
const { reference } = req.query;
if (!reference) return res.status(400).send('No reference');
try {
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') {
res.redirect(`/order-confirmation?ref=${reference}`);
} else {
res.redirect(`/payment-failed?ref=${reference}&status=${verification.data.status}`);
}
} catch (err) {
res.redirect('/payment-failed?error=verification');
}
});
app.listen(3000, () => console.log('Server running on port 3000'));
This is a starting point, not production code. For production, you need:
- A database to store orders, references, and payment statuses.
- Idempotent order processing (check if the reference was already processed before updating).
- Error handling and logging for every external API call.
- A job queue (BullMQ, Agenda, or similar) for webhook processing, so slow downstream operations do not block your webhook response.
- Rate limiting on your payment initialization endpoint to prevent abuse.
- HTTPS. Paystack webhooks require HTTPS in production.
Common Errors and How to Fix Them
These are the errors Kenyan developers hit most often when integrating M-Pesa through Paystack Checkout.
"Channel not available for this currency"
You set the currency to something other than KES, or you did not set it at all. M-Pesa (mobile_money) is only available when the currency is KES. Fix: set currency: "KES" in the Initialize Transaction request.
"Transaction amount is too small"
Paystack enforces minimum transaction amounts. If you send an amount below the minimum (in cents), you get this error. Check Paystack's documentation for the current minimum for M-Pesa transactions in Kenya. Remember that the amount is in cents, so KES 10 is 1000, not 10.
Webhook signature mismatch
This happens when your secret key in the webhook handler does not match the key that was used when the transaction was created, or when a middleware (like a body parser) modifies the raw request body before you compute the HMAC. Fix: ensure you are using the same secret key, and that the body you hash is the exact JSON string Paystack sent.
Customer says they paid but your system shows no payment
This usually means the webhook was not received or was not processed. Check: is your webhook URL configured correctly in the Paystack dashboard? Is your server reachable from the internet? Did your webhook handler return 200? Check the Paystack dashboard "Webhook" logs to see if the webhook was sent and what response your server returned.
Duplicate order processing
Both the webhook and the callback handler update the order. If both run and neither checks whether the order was already marked as paid, you get double processing. Fix: before marking an order as paid, check if it is already paid. Use the transaction reference as the idempotency key.
Checkout vs Charge API: Which to Use for M-Pesa
Paystack gives you two ways to accept M-Pesa. This article covers the Checkout approach (redirect or Inline JS). The companion article covers the Charge API approach.
Use Checkout when:
- You want the fastest integration. Initialize a transaction, redirect, done.
- You want Paystack to handle the payment method selection UI, the phone number input, the "waiting for payment" state, and the error messages.
- You are offering multiple payment methods (M-Pesa, cards, Pesalink) and want Paystack to handle the method selection.
- PCI compliance matters. Paystack Checkout means card data never touches your server.
Use the Charge API when:
- You want a seamless experience with no redirect or popup. The customer stays on your page throughout.
- You already have the customer's phone number (from registration or a previous step) and do not want them to re-enter it on a Paystack page.
- You need full control over the "waiting for payment" UI.
- Your product is M-Pesa only and you want to eliminate every unnecessary step.
For the broader decision between Paystack and direct Daraja (skipping the gateway entirely), see Paystack vs Daraja: When to Use a Gateway and When to Go Direct.
Key Takeaways
- ✓Paystack Checkout (redirect or Inline JS) supports M-Pesa as a payment channel in Kenya. You initialize a transaction with channels: ["mobile_money"] and currency: "KES".
- ✓The customer flow is: click Pay, redirect to Paystack (or see popup), select M-Pesa, enter phone number, receive STK push, enter PIN, get redirected back to your site.
- ✓Always handle the charge.success webhook server-side. Do not rely on the redirect callback alone, because the redirect can fail, be slow, or be manipulated.
- ✓Verify every transaction by calling the Verify Transaction endpoint with the reference. Check the status, amount, and currency before granting value to the customer.
- ✓M-Pesa STK push has a timeout window. If the customer does not respond in time, the transaction fails. Your UI should communicate this clearly and offer a retry.
- ✓Test mode in Paystack lets you simulate M-Pesa transactions without real money. Use test keys and Paystack's test phone numbers.
- ✓For a seamless no-redirect M-Pesa experience, consider the Charge API instead. See the companion article on accepting M-Pesa with the Paystack Charge API.
Frequently Asked Questions
- Do I need the customer's phone number before initializing the transaction?
- No. When using the Checkout flow (redirect or Inline JS), the customer enters their phone number on the Paystack checkout page. You only need their email address to initialize the transaction. If you want to pre-fill or skip the phone number step, use the Charge API instead.
- Can I restrict the checkout to only show M-Pesa, without cards or Pesalink?
- Yes. Set channels: ["mobile_money"] in the Initialize Transaction request. This limits the checkout page to mobile money options only (M-Pesa and Airtel Money in Kenya). The customer will not see card or bank transfer options.
- What happens if my webhook endpoint is down when Paystack sends the event?
- Paystack retries webhook delivery on a schedule. If your server is temporarily unreachable, Paystack will try again later. You should still implement a sweeper job that periodically checks the status of pending transactions via the Verify Transaction endpoint, as a backup for missed webhooks.
- Can I test M-Pesa payments without a real phone number?
- Yes. In test mode (using your sk_test_ key), Paystack simulates the M-Pesa flow. No real STK push is sent. The payment is simulated and your webhook handler receives the event as if it were a real payment. This lets you test your entire integration without real money or a real phone.
- Why does the customer see "Paystack" instead of my business name on the M-Pesa prompt?
- When you accept M-Pesa through Paystack, the STK push goes through Paystack's aggregator paybill. The M-Pesa prompt shows Paystack's registered name, not yours. This is a consequence of the aggregator model. If you need your own business name on the M-Pesa prompt, you need to integrate directly with Safaricom Daraja using your own paybill number.
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