To build a Kenyan e-commerce checkout with Paystack, initialize transactions with channels set to ["mobile_money", "card"] and currency set to KES. Present M-Pesa as the default payment option since most Kenyan shoppers pay with mobile money. Handle charge.success webhooks to confirm orders, verify the amount server-side, and update order status. Use metadata to pass the order ID through the payment flow so your webhook handler can match payments to orders.
A Kenyan e-commerce checkout has a few moving parts that work together. The customer browses products, adds items to a cart, enters their delivery details, picks a payment method, pays, and gets a confirmation. From the developer side, the interesting part is everything between "picks a payment method" and "gets a confirmation."
Here is the architecture at a high level:
The critical design decision: your API creates the order first, then initializes the Paystack transaction with the order ID in the metadata. When the webhook fires, you look up the order by ID and mark it as paid. This decouples order creation from payment completion, which matters because M-Pesa payments are asynchronous. The customer might close the browser between tapping "Pay" and completing the STK push on their phone.
Do not store cart state only on the client. If the customer opens your site on their phone browser, adds items, then switches to another app and comes back, you want the cart to still be there. Use localStorage for guest carts and a server-side cart table for logged-in users.
You need four core tables. Keep them simple at the start and extend them when the business requires it.
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
phone VARCHAR(20),
name VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID REFERENCES customers(id),
status VARCHAR(30) DEFAULT 'pending_payment',
-- status values: pending_payment, paid, processing, shipped, delivered, cancelled, refunded
total_cents INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'KES',
shipping_address JSONB,
paystack_reference VARCHAR(255),
paystack_channel VARCHAR(50), -- 'card' or 'mobile_money'
paid_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL,
product_name VARCHAR(255) NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type VARCHAR(100) NOT NULL,
paystack_reference VARCHAR(255),
payload JSONB NOT NULL,
processed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
A few design notes:
150000. This matches what Paystack expects and avoids floating point issues. Every price in your system should be an integer in cents.Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
The cart lives on the client until the customer clicks "Checkout." At that point, your frontend sends the cart items to your API, which validates the prices, creates the order, and returns a transaction initialization URL or access code.
// POST /api/checkout
app.post('/api/checkout', async (req, res) => {
const { items, customer, shippingAddress } = req.body;
// 1. Validate the cart items against your product catalog
const validatedItems = [];
let totalCents = 0;
for (const item of items) {
const product = await db.query(
'SELECT id, name, price_cents, stock FROM products WHERE id = $1',
[item.productId]
);
if (!product.rows[0]) {
return res.status(400).json({ error: `Product ${item.productId} not found` });
}
if (product.rows[0].stock < item.quantity) {
return res.status(400).json({
error: `${product.rows[0].name} only has ${product.rows[0].stock} in stock`
});
}
const lineTotal = product.rows[0].price_cents * item.quantity;
totalCents += lineTotal;
validatedItems.push({
productId: product.rows[0].id,
productName: product.rows[0].name,
quantity: item.quantity,
unitPriceCents: product.rows[0].price_cents,
});
}
// 2. Create the order
const order = await db.query(
`INSERT INTO orders (customer_id, total_cents, currency, shipping_address, status)
VALUES ($1, $2, 'KES', $3, 'pending_payment')
RETURNING id`,
[customer.id, totalCents, JSON.stringify(shippingAddress)]
);
const orderId = order.rows[0].id;
// 3. Insert order items
for (const item of validatedItems) {
await db.query(
`INSERT INTO order_items (order_id, product_id, product_name, quantity, unit_price_cents)
VALUES ($1, $2, $3, $4, $5)`,
[orderId, item.productId, item.productName, item.quantity, item.unitPriceCents]
);
}
// 4. Initialize the Paystack transaction
const paystackRes = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: customer.email,
amount: totalCents,
currency: 'KES',
channels: ['mobile_money', 'card'],
callback_url: `${process.env.APP_URL}/order/${orderId}/confirm`,
metadata: {
order_id: orderId,
custom_fields: [
{ display_name: 'Order ID', variable_name: 'order_id', value: orderId },
],
},
}),
});
const paystackData = await paystackRes.json();
// 5. Save the reference
await db.query(
'UPDATE orders SET paystack_reference = $1 WHERE id = $2',
[paystackData.data.reference, orderId]
);
res.json({
authorization_url: paystackData.data.authorization_url,
reference: paystackData.data.reference,
order_id: orderId,
});
});
The key thing happening here: server-side price validation. Never accept the price from the frontend. A customer (or an attacker) can modify the cart total in JavaScript. Your API must look up the actual price from your database and calculate the total itself.
Notice the channels: ['mobile_money', 'card'] parameter. This tells Paystack which payment options to show. When the customer opens the authorization_url, they see M-Pesa and card options. You can also include 'bank_transfer' or 'apple_pay' if you want those.
If you build a Kenyan checkout that shows a card form first and hides M-Pesa behind a tab, you will lose customers. This is not opinion. It is the reality of how people pay in Kenya.
When using Paystack's hosted checkout (the authorization_url redirect), Paystack handles the payment method selection UI for you. But if you are building a custom checkout experience using the Charge API directly, the UX decisions are yours.
Here is what an M-Pesa-first custom checkout should do:
For the Charge API approach (custom checkout), here is how you initiate the M-Pesa charge:
// POST /api/pay/mpesa
app.post('/api/pay/mpesa', async (req, res) => {
const { orderId, phone } = req.body;
const order = await db.query('SELECT * FROM orders WHERE id = $1', [orderId]);
if (!order.rows[0] || order.rows[0].status !== 'pending_payment') {
return res.status(400).json({ error: 'Invalid order' });
}
const chargeRes = await fetch('https://api.paystack.co/charge', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: req.user.email,
amount: order.rows[0].total_cents,
currency: 'KES',
mobile_money: {
phone: phone,
provider: 'mpesa',
},
metadata: {
order_id: orderId,
},
}),
});
const chargeData = await chargeRes.json();
await db.query(
'UPDATE orders SET paystack_reference = $1 WHERE id = $2',
[chargeData.data.reference, orderId]
);
// The status will be 'pay_offline' meaning the customer needs to complete on their phone
res.json({
status: chargeData.data.status,
reference: chargeData.data.reference,
display_text: chargeData.data.display_text,
});
});
After this endpoint returns, your frontend polls a status endpoint every 3 to 5 seconds to check if the webhook has arrived and updated the order status. Do not poll Paystack directly from the frontend. Poll your own backend.
This is where your checkout becomes reliable. The webhook is the source of truth for payment confirmation, not the redirect callback. A customer might close their browser, lose their connection, or get distracted between paying and landing on your confirmation page. The webhook fires regardless.
const crypto = require('crypto');
app.post('/webhooks/paystack', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify the signature
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(req.body)
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// 2. Store the raw event immediately
await db.query(
'INSERT INTO webhook_events (event_type, paystack_reference, payload) VALUES ($1, $2, $3)',
[event.event, event.data.reference, JSON.stringify(event)]
);
// 3. Return 200 right away. Process async.
res.status(200).send('OK');
// 4. Process the event
if (event.event === 'charge.success') {
await handleChargeSuccess(event.data);
}
});
async function handleChargeSuccess(data) {
const { reference, amount, currency, metadata, channel } = data;
// 1. Find the order
const order = await db.query(
'SELECT * FROM orders WHERE paystack_reference = $1',
[reference]
);
if (!order.rows[0]) {
console.error('No order found for reference:', reference);
return;
}
const existingOrder = order.rows[0];
// 2. Idempotency: skip if already paid
if (existingOrder.status === 'paid') {
return;
}
// 3. Verify the amount matches
if (amount !== existingOrder.total_cents || currency !== existingOrder.currency) {
console.error('Amount mismatch:', { expected: existingOrder.total_cents, received: amount });
await db.query(
"UPDATE orders SET status = 'flagged', metadata = metadata || $1 WHERE id = $2",
[JSON.stringify({ flag_reason: 'amount_mismatch', received_amount: amount }), existingOrder.id]
);
return;
}
// 4. Update the order
await db.query(
`UPDATE orders SET status = 'paid', paystack_channel = $1, paid_at = NOW(), updated_at = NOW()
WHERE id = $2`,
[channel, existingOrder.id]
);
// 5. Send confirmation (email, SMS, or both)
await sendOrderConfirmation(existingOrder.id);
}
Critical details in this handler:
For the full webhook engineering guide, see Paystack Webhooks: Complete Engineering Guide.
After the customer pays, they need to see a confirmation. There are two paths depending on which checkout method you used.
If you used Transaction Initialize (redirect): Paystack redirects the customer to your callback_url with the transaction reference as a query parameter. Your confirmation page loads, hits your API to check the order status, and shows the result.
If you used the Charge API (custom checkout): Your frontend is already polling your API. When the webhook arrives and updates the order status to "paid," the next poll picks it up.
// GET /api/orders/:id/status (for frontend polling)
app.get('/api/orders/:id/status', async (req, res) => {
const order = await db.query(
'SELECT id, status, total_cents, currency, paystack_channel, paid_at FROM orders WHERE id = $1',
[req.params.id]
);
if (!order.rows[0]) {
return res.status(404).json({ error: 'Order not found' });
}
res.json({
status: order.rows[0].status,
paid: order.rows[0].status === 'paid',
channel: order.rows[0].paystack_channel,
paid_at: order.rows[0].paid_at,
});
});
// GET /api/orders/:id/confirm (callback URL handler)
app.get('/api/orders/:id/confirm', async (req, res) => {
const { trxref } = req.query;
// Do not trust the callback alone. Verify with Paystack.
const verifyRes = await fetch(
`https://api.paystack.co/transaction/verify/${trxref}`,
{
headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` },
}
);
const verifyData = await verifyRes.json();
if (verifyData.data.status === 'success') {
// The webhook may have already processed this.
// But if not, update the order here as a safety net.
await db.query(
"UPDATE orders SET status = 'paid', paid_at = NOW() WHERE paystack_reference = $1 AND status = 'pending_payment'",
[trxref]
);
}
// Redirect to the frontend confirmation page
res.redirect(`${process.env.FRONTEND_URL}/order/${req.params.id}/thank-you`);
});
Notice the double-safety pattern: the webhook updates the order, and the callback verification also updates it (but only if it is still pending). This handles the race condition where the customer reaches the callback URL before the webhook arrives. The AND status = 'pending_payment' clause prevents the callback from overwriting a more advanced state.
Shipping in Kenya is not like shipping in the US. There are no universal postal addresses. There is no reliable nationwide courier that covers every village. Your checkout needs to handle this reality.
Delivery zones and pricing. Most Kenyan e-commerce businesses split delivery into zones: Nairobi CBD, greater Nairobi, major towns (Mombasa, Kisumu, Nakuru, Eldoret), and the rest of the country. Delivery fees and timelines differ by zone. Build your shipping calculator around zones, not postal codes.
Pickup points. Many customers prefer to pick up orders at a physical location rather than pay for delivery. Partner with a pickup network (G4S agents, local shops, courier pickup points) and offer pickup as a delivery option at checkout. This is often free or cheaper for the customer.
Address format. Kenyan delivery addresses are often descriptions, not structured addresses. "Next to Uchumi, Ngong Road" or "Karibu Estate, Block 4, House 7." Your address form should have a free-text field for location description alongside the standard fields. A Google Maps pin or GPS coordinates field is also useful if your delivery partner supports it.
Cash on delivery. Some customers still want to pay when the goods arrive. If you offer this, you need a separate order flow that skips the Paystack payment step and marks the order as "cash_on_delivery" instead of "pending_payment." Your delivery partner collects the cash and remits it to you. This adds reconciliation complexity, but refusing COD means losing a segment of customers who do not trust paying online for physical goods yet.
For a checkout form, collect: name, phone number (mandatory for delivery coordination), county or town, specific location/landmark description, and optionally building/house number and GPS coordinates.
The happy path is straightforward: customer pays, webhook fires, order confirmed. The real engineering is in the unhappy paths.
STK push not received. The customer taps "Pay" but nothing appears on their phone. This happens when Safaricom's systems are slow, the customer's phone is off, or the phone number is wrong. Show a retry option after 30 seconds. Let the customer re-enter their phone number.
Customer pays but webhook is delayed. Paystack's webhook might arrive seconds or minutes after the payment. Your polling endpoint should check the order status every 5 seconds for 2 minutes, then stop and show a message: "Your payment is being processed. You will receive a confirmation SMS shortly."
Duplicate payment attempts. The customer gets impatient, goes back, and tries to pay again. Your checkout endpoint should check if an existing pending transaction exists for the order before creating a new one. If there is a pending charge, return that reference instead of creating a duplicate.
// Before initializing a new transaction, check for existing ones
const existingOrder = await db.query(
"SELECT paystack_reference FROM orders WHERE id = $1 AND status = 'pending_payment' AND paystack_reference IS NOT NULL",
[orderId]
);
if (existingOrder.rows[0]) {
// Verify the existing transaction status with Paystack
const verifyRes = await fetch(
`https://api.paystack.co/transaction/verify/${existingOrder.rows[0].paystack_reference}`,
{ headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
);
const verifyData = await verifyRes.json();
if (verifyData.data.status === 'abandoned' || verifyData.data.status === 'failed') {
// Old transaction is dead, create a new one
} else {
// Return the existing transaction
return res.json({
reference: existingOrder.rows[0].paystack_reference,
message: 'Existing payment in progress',
});
}
}
Stock sold out between cart and payment. Check stock levels again when the webhook confirms payment. If an item has sold out in the meantime, you need a clear refund or back-order policy. Refunds through Paystack are straightforward, but they take time to process. Communicate the timeline to the customer.
Network interruptions. On Kenyan mobile networks, requests can hang for 10+ seconds. Set appropriate timeouts on your Paystack API calls (30 seconds is reasonable) and wrap them in try/catch blocks. If the initialization call fails, the customer should see a "Try again" button, not a blank page.
Deploying an e-commerce checkout that serves Kenyan customers has specific requirements beyond the usual "push to production" workflow.
Webhook URL must be HTTPS. Paystack requires a publicly accessible HTTPS URL for webhooks. If you are on Vercel, Netlify, or Railway, this is automatic. If you are running your own VPS, set up Let's Encrypt with Certbot.
Server location matters for latency. If your customers are in Kenya and your server is in US East, every API call adds 200-400ms of latency. Use a cloud region closer to East Africa. AWS has an Africa (Cape Town) region. Vercel and Cloudflare Workers have edge nodes in Nairobi. The closer your server is to both your customers and Paystack's API, the faster your checkout feels.
Environment variables. Use PAYSTACK_SECRET_KEY for the secret key and PAYSTACK_PUBLIC_KEY for the publishable key. Keep a separate Paystack test account for staging. Never use live keys in development or CI.
Test with both payment methods. Before going live, test the complete flow with M-Pesa and cards in Paystack's test mode. M-Pesa test mode simulates the STK push flow without touching real money. Card test mode uses Paystack's test card numbers. Test the webhook flow end to end, including the confirmation page.
eTIMS integration. If your business is registered for VAT in Kenya, you need to generate eTIMS-compliant electronic tax invoices for every sale. Trigger the eTIMS invoice creation in the same webhook handler that confirms the order. See KRA eTIMS and Payment Receipt Integration for details.
SMS confirmations. Email open rates in Kenya are low for consumer transactions. Send an SMS confirmation after payment. Use Africa's Talking, Twilio, or another SMS provider that delivers reliably on Safaricom, Airtel, and Telkom networks. The SMS should include the order number, amount paid, and expected delivery timeline.
This guide covers the core checkout flow. For the specifics of each component, the following articles go deeper:
If you want to build a production e-commerce checkout with real mentorship, the McTaba 26-week Full-Stack Software and AI Engineering bootcamp (KES 120,000) includes payment integration as a core module. You ship a real product that accepts real money. For M-Pesa and Daraja specifically, the M-Pesa Integration micro-course covers the complete stack in four weeks.
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