Bonaventure OgetoBy Bonaventure Ogeto|

Building a Kenyan E-Commerce Checkout That Accepts M-PESA and Cards

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.

Architecture Overview

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:

  1. Your frontend renders the product catalog, manages the cart in local storage or a session, and collects shipping information.
  2. Your Express API creates orders in the database, initializes Paystack transactions, and handles webhook events.
  3. Paystack processes the payment. For M-Pesa, it triggers an STK push. For cards, it handles 3D Secure. It sends you a webhook when the charge completes.
  4. Your database stores orders, tracks payment status, and holds the order history.

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.

Data Model

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:

  • Store amounts in cents. KES 1,500 is stored as 150000. This matches what Paystack expects and avoids floating point issues. Every price in your system should be an integer in cents.
  • The webhook_events table is your safety net. Store every webhook payload you receive, whether you processed it or not. When something goes wrong (and it will), you can replay events from this table.
  • Order status is a state machine. Define the valid transitions and enforce them in code. A "delivered" order should never go back to "pending_payment."
  • shipping_address as JSONB gives you flexibility. Kenyan addresses do not always fit into street/city/zip fields neatly. Many deliveries go to landmarks, estate names, or pickup points.

Cart Management and Order Creation

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.

M-Pesa-First UX Design

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:

  • Default to M-Pesa. The phone number input should be visible and ready when the checkout page loads. The card form should be accessible but not the default view.
  • Pre-fill the phone number if the customer is logged in and you already have their number. One fewer field to fill means faster checkout.
  • Show the amount prominently in KES. "You are paying KES 2,500" in large text. The customer needs to match this against the STK push amount on their phone.
  • Explain what happens next. After they tap "Pay with M-Pesa," tell them: "Check your phone for the M-Pesa prompt and enter your PIN." Many first-time online shoppers do not know what an STK push is.
  • Show a polling state. After initiating the charge, show a spinner or progress indicator with "Waiting for M-Pesa confirmation..." The customer needs to know the page is waiting for them to complete the action on their phone.
  • Handle the timeout. If 60 seconds pass without confirmation, show a "Did not receive the prompt?" message with options to retry or try a different number.

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.

Webhook Handling for Both Payment Types

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:

  • Return 200 immediately. Do your processing after sending the response. If your handler takes too long, Paystack will time out and retry, causing duplicate events.
  • Use express.raw(), not express.json(). You need the raw request body to compute the HMAC signature correctly. If you parse it first with express.json(), the signature check will fail because the serialized JSON may differ from the original bytes Paystack sent. This is the most common webhook bug in Paystack integrations.
  • Idempotency check. If the order is already "paid," do nothing. Paystack can send the same webhook multiple times.
  • Amount verification. Compare the webhook amount against what you stored when creating the order. If they do not match, flag the order for manual review. Never silently accept a mismatched amount.
  • The channel field tells you whether the customer paid with M-Pesa or a card. Store it. Your support team will need this information.

For the full webhook engineering guide, see Paystack Webhooks: Complete Engineering Guide.

Order Status Updates and Polling

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 Considerations for Kenya

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.

Error Handling and Edge Cases

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.

Deployment Notes for Kenyan Conditions

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.

Next Steps

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.

Key Takeaways

  • Present M-Pesa as the first payment option in your Kenyan checkout. Over 90% of digital payments in Kenya flow through mobile money. Burying it behind a card form kills your conversion rate.
  • Use Paystack Transaction Initialize with channels ["mobile_money", "card"] to offer both payment methods from a single integration. Your backend code stays the same regardless of what the customer picks.
  • Pass your order ID in the Paystack metadata field so your webhook handler can match the charge.success event to the correct order without extra database lookups.
  • Always verify the amount and currency server-side in your webhook handler before marking an order as paid. Never trust client-side data for payment confirmation.
  • Build your order status as a state machine: cart, pending_payment, paid, processing, shipped, delivered. This makes webhook handling and customer support much cleaner.
  • Handle the M-Pesa timeout case. STK push can take 30 to 60 seconds or fail silently if the customer ignores it. Show a polling UI and fall back to manual confirmation after a timeout.
  • Kenyan shipping adds real complexity. Account for Nairobi vs upcountry delivery, pickup points, and cash-on-delivery preferences in your checkout flow.

Frequently Asked Questions

Should I use Paystack Checkout (redirect) or the Charge API for my Kenyan e-commerce site?
Start with Paystack Checkout (Transaction Initialize with redirect). It is faster to implement, handles payment method selection for you, and Paystack maintains the UI. Use the Charge API only when you need full control over the checkout UX, like pre-filling the M-Pesa phone number or building a single-page checkout that never leaves your site.
How do I handle the case where a customer pays but closes the browser before the confirmation page loads?
The webhook handles this. Your webhook handler runs server-side and does not depend on the customer's browser. It marks the order as paid regardless of what the customer does on the frontend. Send an SMS or email confirmation so the customer knows their payment went through even if they never see the confirmation page.
Can I offer M-Pesa and cards in the same checkout without building two separate flows?
Yes. When you use Transaction Initialize with channels set to ["mobile_money", "card"], Paystack shows both options in a single checkout. Your backend code is identical for both. The webhook payload structure is the same. The only difference is the channel field in the webhook data, which tells you which method the customer used.
What happens if the M-Pesa STK push times out?
The transaction status stays as "pending" on Paystack until it either completes or expires. From your side, poll your order status endpoint for about 2 minutes. If the payment does not confirm, show the customer an option to retry. Do not create a new order. Reuse the same order and create a new Paystack transaction for it.
Do I need to handle Pesalink and Airtel Money separately?
No. If you include them in your channels array, Paystack handles the checkout flow for each payment method. Your webhook handler receives the same charge.success event regardless of the method. You do not need separate code paths for each payment type.

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