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.
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:
POST /transaction/initialize with the amount in cents (KES), the customer's email, and channels: ["mobile_money"].mobile_money as the only channel, the page skips the method selection and goes straight to the M-Pesa payment form. The customer enters their phone number.charge.success webhook to your server.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.
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:
150000. This is not a Kenyan convention; it is how Paystack handles all currencies. Multiply your KES amount by 100.["mobile_money", "card"]. If you omit channels, Paystack shows all available methods.Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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:
x-paystack-signature header 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.charge.success for 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.
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:
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:
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:
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.
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:
sk_test_) instead of your live key.Test mode behavior for M-Pesa:
charge.success events for test transactions. This lets you test your entire flow end-to-end.What to verify during testing:
charge.success event.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.
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:
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.
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:
Use the Charge API when:
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.
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