Bonaventure OgetoBy Bonaventure Ogeto|

USSD Plus Paystack: Reaching Feature Phone Users in Kenya

Paystack does not support USSD payments in Kenya directly. To reach feature phone users, build a USSD menu using Africa's Talking or a similar USSD provider as your frontend. When the user selects a product or service through the USSD menu, generate a Paystack payment link on your server and deliver it via SMS. The user opens the link on a basic browser or receives an M-Pesa STK push. For users without any data access, collect their phone number through USSD and initiate the Paystack Charge API with the mobile_money channel, which triggers an STK push to their M-Pesa phone.

The Feature Phone Reality in Kenya

Kenya has over 60 million mobile subscriptions, but a significant portion of those are on feature phones. These are the Nokia 105s, the Tecno T301s, and the itel it2180s that dominate rural markets and budget-conscious urban users. Feature phones cannot run apps, cannot open modern web pages, and cannot render JavaScript-driven checkout flows.

But feature phones can do two things that matter: navigate USSD menus and receive SMS messages. Every M-Pesa transaction in Kenya started on USSD. The *334# or *150# shortcode menus are second nature to tens of millions of Kenyans. If your product serves rural areas, agricultural communities, low-income urban populations, or anyone who has not upgraded to a smartphone, you need a USSD interface.

The problem is that Paystack's checkout is web-based. You cannot display a Paystack checkout page on a feature phone. And Paystack does not offer USSD as a direct payment channel in Kenya (they do in Nigeria, but the Kenya integration works differently).

The solution is a hybrid architecture: USSD for the user interface, your server for the business logic, and Paystack for payment processing. The payment itself happens through M-Pesa (which works on every phone) or through an SMS payment link (for users with basic internet).

The Hybrid Architecture: USSD + Server + Paystack

The system has three layers that work together.

Layer 1: USSD provider. Africa's Talking, Hover, or another USSD gateway provides the shortcode (e.g., *384*123#) and routes USSD sessions to your server. When a user dials the shortcode, the provider sends a request to your webhook URL with the user's input. You respond with menu text, and the provider displays it on the user's phone. This back-and-forth continues until the session ends.

Layer 2: Your server. Your Express or Django server handles the USSD session logic: menu navigation, product selection, quantity, and customer details. When the user confirms their order, your server creates a Paystack transaction and either sends an SMS with the payment link or initiates an M-Pesa STK push through the Paystack Charge API.

Layer 3: Paystack. Processes the actual payment. Sends webhooks when the payment succeeds or fails. Settles funds to your account.

// The flow
// 1. User dials *384*123#
// 2. Africa's Talking sends POST to your server
// 3. Your server responds with menu text
// 4. User navigates menu (product > quantity > confirm)
// 5. Your server creates Paystack transaction
// 6. Option A: Send SMS with payment link
//    Option B: Initiate M-Pesa STK push via Paystack Charge API
// 7. User completes payment on their phone
// 8. Paystack sends charge.success webhook
// 9. Your server fulfils the order (SMS confirmation, delivery, etc.)

This architecture keeps each layer doing what it is best at. USSD handles the interface on devices that cannot do anything else. Your server handles business logic and session management. Paystack handles payment security, compliance, and settlement.

Building the USSD Menu with Africa's Talking

Africa's Talking is the most widely used USSD provider in Kenya. You register a shortcode, point the callback URL to your server, and handle sessions.

A USSD session has three properties: sessionId (unique per session), phoneNumber (the caller), and text (the user's accumulated input, separated by * for each menu level).

const express = require('express');
const app = express();
app.use(express.urlencoded({ extended: true }));

// Product catalog (simplified)
const products = [
  { id: 1, name: 'Weekly Data Bundle', price: 10000 },  // KES 100
  { id: 2, name: 'Monthly Subscription', price: 50000 }, // KES 500
  { id: 3, name: 'Event Ticket', price: 30000 },         // KES 300
];

app.post('/ussd', async (req, res) => {
  const { sessionId, phoneNumber, text } = req.body;
  const inputs = text.split('*').filter(Boolean);
  let response = '';

  if (inputs.length === 0) {
    // Main menu
    response = 'CON Welcome to YourApp\n';
    response += '1. Buy a product\n';
    response += '2. Check my orders\n';
    response += '3. My account';

  } else if (inputs[0] === '1' && inputs.length === 1) {
    // Product listing
    response = 'CON Select a product:\n';
    products.forEach((p, i) => {
      response += `${i + 1}. ${p.name} - KES ${p.price / 100}\n`;
    });

  } else if (inputs[0] === '1' && inputs.length === 2) {
    // Product selected, confirm
    const productIndex = parseInt(inputs[1]) - 1;
    const product = products[productIndex];
    if (!product) {
      response = 'END Invalid selection. Please try again.';
    } else {
      response = `CON You selected ${product.name} for KES ${product.price / 100}.\n`;
      response += '1. Confirm and pay\n';
      response += '2. Cancel';
    }

  } else if (inputs[0] === '1' && inputs.length === 3 && inputs[2] === '1') {
    // Confirmed - initiate payment
    const productIndex = parseInt(inputs[1]) - 1;
    const product = products[productIndex];

    try {
      await initiatePaymentForUSSD(sessionId, phoneNumber, product);
      response = 'END Order placed! You will receive an M-Pesa payment prompt shortly. Enter your PIN to complete payment.';
    } catch (error) {
      response = 'END Sorry, we could not process your order. Please try again later.';
    }

  } else if (inputs[0] === '1' && inputs.length === 3 && inputs[2] === '2') {
    response = 'END Order cancelled. Dial *384*123# to start again.';

  } else if (inputs[0] === '2') {
    // Check orders
    const orders = await getRecentOrders(phoneNumber);
    if (orders.length === 0) {
      response = 'END You have no recent orders.';
    } else {
      response = 'END Recent orders:\n';
      orders.forEach(o => {
        response += `${o.product_name} - KES ${o.amount / 100} - ${o.status}\n`;
      });
    }

  } else {
    response = 'END Invalid selection. Dial *384*123# to start again.';
  }

  res.set('Content-Type', 'text/plain');
  res.send(response);
});

CON versus END. Responses starting with "CON" keep the USSD session open and wait for user input. Responses starting with "END" close the session. Always end the session after initiating payment or displaying final information. Do not leave sessions hanging.

Session timeout. USSD sessions on Kenyan networks typically expire after 3 to 5 minutes of inactivity. If the user takes too long between menu levels, the session dies and they have to start over. Keep menus short and get to the payment step quickly.

Triggering M-Pesa Payment via Paystack Charge API

The most seamless approach for feature phone users: after they confirm their order on the USSD menu, send an M-Pesa STK push to their phone. No SMS link needed, no browser needed. The STK push appears on their phone, they enter their M-Pesa PIN, and the payment is done.

async function initiatePaymentForUSSD(sessionId, phoneNumber, product) {
  const reference = `ussd_${sessionId}_${Date.now()}`;

  // Create order record
  await db.query(
    `INSERT INTO orders (phone, product_id, product_name, amount, paystack_reference, status, source)
     VALUES ($1, $2, $3, $4, $5, 'pending', 'ussd')`,
    [phoneNumber, product.id, product.name, product.price, reference]
  );

  // Use Paystack Charge API with mobile_money channel
  const response = await axios.post(
    'https://api.paystack.co/charge',
    {
      email: `${phoneNumber.replace('+', '')}@ussd.yourapp.com`,
      amount: product.price,
      currency: 'KES',
      mobile_money: {
        phone: phoneNumber,
        provider: 'mpesa',
      },
      reference: reference,
      metadata: {
        source: 'ussd',
        session_id: sessionId,
        product_id: product.id,
      },
    },
    { headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
  );

  return response.data;
}

The timing matters. The USSD session and the STK push are independent. The USSD session ends when you send the "END" response. The STK push arrives a few seconds later. The user sees: the USSD session closes, then the M-Pesa prompt appears. This handoff feels natural because Kenyans are used to USSD-to-M-Pesa flows from Safaricom's own services.

Handling the response. The Paystack Charge API may return a "pending" status, meaning the STK push has been sent but the user has not entered their PIN yet. Do not treat pending as failure. Listen for the charge.success webhook to confirm payment. If the user does not complete the STK push (they ignore it or enter wrong PIN), the transaction expires and you receive no webhook. Handle this by marking the order as expired after a timeout (5 to 10 minutes).

// Webhook handler for USSD-initiated payments
async function handleUSSDPaymentSuccess(data) {
  const reference = data.reference;

  const order = await db.query(
    `UPDATE orders SET status = 'paid', paid_at = NOW()
     WHERE paystack_reference = $1 AND status = 'pending'
     RETURNING *`,
    [reference]
  );

  if (order.rowCount === 0) return;

  const o = order.rows[0];

  // Confirm via SMS (the user cannot receive in-app notifications)
  await sendSMS(
    o.phone,
    `Payment confirmed! Your order for ${o.product_name} (KES ${o.amount / 100}) has been processed. Thank you.`
  );

  // Fulfil the order (activate subscription, send ticket, etc.)
  await fulfilOrder(o);
}

USSD Menu for Account Management

Beyond payments, USSD menus can handle account management tasks that keep feature phone users engaged with your service.

Check balance/subscription status. Let users dial the shortcode and select "My Account" to see their current subscription status, last payment date, and balance (if applicable). This is especially useful for subscription services where users want to know if their access is still active.

async function handleAccountCheck(phoneNumber) {
  const account = await db.query(
    `SELECT s.status, s.current_period_end, s.plan_name
     FROM subscriptions s
     JOIN users u ON s.user_id = u.id
     WHERE u.phone = $1 AND s.status = 'active'
     ORDER BY s.created_at DESC LIMIT 1`,
    [phoneNumber]
  );

  if (account.rows.length === 0) {
    return 'END You have no active subscription. Select option 1 from the main menu to subscribe.';
  }

  const sub = account.rows[0];
  return `END Your subscription:\nPlan: ${sub.plan_name}\nStatus: Active\nExpires: ${formatDate(sub.current_period_end)}\nDial *384*123# to renew or change plan.`;
}

Order history. Show the user their last 3 to 5 orders with status (paid, pending, delivered). USSD screens are small (about 160 characters visible), so keep the information dense and abbreviate where necessary.

Update phone number. If a user changes their SIM but keeps the same account, let them update their registered number through USSD by verifying with a code sent to their old number. This is important for services where the phone number is tied to payment and delivery.

Help and support. Include a "Help" option that displays a support phone number or sends an SMS with instructions. Feature phone users cannot visit a help center website, so the support path must stay within SMS and phone calls.

Reaching Rural Kenyan Customers

Rural Kenya has specific challenges that affect your USSD+Paystack integration.

Network reliability. USSD sessions over Safaricom are generally stable, but in rural areas with weak signal, sessions drop more frequently. Design your menu flow so that a dropped session does not lose the user's progress. If they were mid-order when the session dropped, save the session state server-side and let them resume when they dial back.

// Save session state for resume
async function saveSessionState(phoneNumber, state) {
  await db.query(
    `INSERT INTO ussd_sessions (phone, state, updated_at)
     VALUES ($1, $2, NOW())
     ON CONFLICT (phone) DO UPDATE SET state = $2, updated_at = NOW()`,
    [phoneNumber, JSON.stringify(state)]
  );
}

async function getSessionState(phoneNumber) {
  const result = await db.query(
    `SELECT state FROM ussd_sessions
     WHERE phone = $1 AND updated_at > NOW() - INTERVAL '30 minutes'`,
    [phoneNumber]
  );

  return result.rows[0] ? JSON.parse(result.rows[0].state) : null;
}

// At the start of a new USSD session
app.post('/ussd', async (req, res) => {
  const { phoneNumber, text } = req.body;
  const inputs = text.split('*').filter(Boolean);

  if (inputs.length === 0) {
    // Check for saved session
    const savedState = await getSessionState(phoneNumber);
    if (savedState && savedState.productSelected) {
      let response = `CON Welcome back! You were ordering ${savedState.productName}.\n`;
      response += '1. Continue with this order\n';
      response += '2. Start a new order';
      return res.send(response);
    }
    // Show main menu...
  }
});

Language. Consider offering USSD menus in Swahili alongside English. A menu that says "Chagua bidhaa" (Select a product) reaches more people than one that says "Select a product" only in English. Use the user's language preference if stored, or offer language selection as the first menu option.

Agent networks. In some rural areas, M-Pesa agents serve as community access points. Consider building an agent mode where an M-Pesa agent can initiate a purchase on behalf of a customer. The agent dials the USSD code, enters the customer's phone number, and the STK push goes to the customer's phone. The agent assists with the process, and the customer pays from their own M-Pesa account.

Literacy. Keep menu text simple. Short sentences. Common words. Avoid abbreviations that are not universally understood. Test your USSD copy with actual users in the target community before launching.

Testing and Deploying the USSD Integration

Africa's Talking sandbox. Africa's Talking provides a sandbox environment where you can test USSD flows without a live shortcode. Use their USSD simulator to walk through your menu logic before deploying. The simulator mimics a real USSD session, sending requests to your webhook URL and displaying your responses.

// Africa's Talking sandbox configuration
const AfricasTalking = require('africastalking')({
  apiKey: process.env.AT_API_KEY,
  username: process.env.AT_SANDBOX ? 'sandbox' : process.env.AT_USERNAME,
});

// Test sending SMS from sandbox
async function testSMS(phone, message) {
  const sms = AfricasTalking.SMS;
  const result = await sms.send({
    to: [phone],
    message: message,
    // In sandbox, messages are simulated, not actually sent
  });
  return result;
}

Paystack test mode. Use Paystack test keys alongside the Africa's Talking sandbox. Test the full flow: USSD session, order creation, Paystack charge initiation, webhook handling, and SMS confirmation. Paystack test mode simulates successful and failed payments without moving real money.

Live shortcode. To go live, apply for a USSD shortcode through Africa's Talking and the Communications Authority of Kenya. Shared shortcodes (like *384*123#) are cheaper and faster to get. Dedicated shortcodes (like *123#) are expensive and take longer but are easier for users to remember. Most startups begin with shared shortcodes.

Monitoring. Track three metrics in production: USSD session completion rate (how many users who start a session reach the payment step), payment conversion rate (how many users who reach the payment step complete payment), and session drop rate (how many sessions die due to timeout or network issues). These metrics tell you where users are getting stuck and where the experience needs improvement.

For the full context on Paystack payment methods in Kenya, including M-Pesa, PesaLink, and cards, see our Paystack in Kenya hub guide.

When This Architecture Makes Sense

Not every product needs a USSD interface. This architecture is the right choice when your target audience includes a significant number of feature phone users, your product is simple enough to navigate in 3 to 4 USSD menu levels, and the payment amount justifies the development effort.

Good candidates. Agricultural input ordering (seeds, fertilizer) for smallholder farmers. Utility bill payments in areas without smartphone penetration. Subscription services for rural content (agricultural tips, market prices, weather alerts). Event ticket sales for community events. Savings group contributions. Insurance premium collection for micro-insurance products.

Not ideal for. Products that require browsing (e-commerce with many products), services that need rich media (video streaming, image-heavy content), or flows that require complex input (long forms, file uploads). If the user needs to make choices from more than 10 options, USSD becomes unwieldy and a different channel (agent network, call center) serves them better.

The smartphone transition. Smartphone penetration in Kenya is growing fast. The audience you serve via USSD today may be on smartphones in two years. Build your USSD integration as a complement to your web and mobile experiences, not as a replacement. Share the same backend, the same order system, and the same Paystack integration. The only difference is the frontend: USSD text versus a web page. When a user upgrades to a smartphone, their account and history carry over seamlessly.

Key Takeaways

  • Paystack does not offer a native USSD payment channel in Kenya. You need a separate USSD provider (Africa's Talking, Hover, or similar) to build the USSD menu, and Paystack handles the actual payment processing.
  • The architecture is: USSD menu as the user interface, your server as the bridge, and Paystack as the payment backend. The USSD session collects user intent, your server initiates the Paystack transaction, and the payment completes via SMS link or M-Pesa STK push.
  • SMS-delivered payment links are the simplest bridge between USSD and Paystack. The user navigates the USSD menu, your server generates a Paystack payment link, and the user receives it via SMS to complete payment.
  • For users with no data at all, use the Paystack Charge API with the mobile_money channel to send an M-Pesa STK push directly to their phone. No browser needed. The USSD session collects their phone number, and you initiate the charge server-side.
  • Keep USSD menus short. Feature phone users are used to quick interactions. Three to four menu levels maximum. Every extra step loses users.
  • Rural Kenya has network reliability challenges. Design for timeouts and retries. USSD sessions expire quickly (usually 3 to 5 minutes), so initiate the payment before the session ends.

Frequently Asked Questions

Does Paystack support USSD payments directly in Kenya?
No, Paystack does not offer USSD as a direct payment channel in Kenya. In Nigeria, Paystack supports bank USSD codes as a payment method, but this is not available in the Kenyan market. To serve USSD users in Kenya, you need a separate USSD provider (like Africa's Talking) for the menu interface and use Paystack for the actual payment processing through M-Pesa or card.
How much does a USSD shortcode cost in Kenya?
Costs vary by provider and shortcode type. Shared shortcodes through Africa's Talking start at lower monthly fees. Dedicated shortcodes are significantly more expensive and require approval from the Communications Authority of Kenya. Check Africa's Talking pricing page for current rates. For most startups, a shared shortcode is sufficient and much more affordable.
Can a user complete payment without any internet access?
Yes, if you use the Paystack Charge API to send an M-Pesa STK push. USSD does not require internet (it uses the GSM signaling channel). M-Pesa STK push also does not require internet on the user phone. The entire flow from USSD menu navigation to M-Pesa PIN entry works on a feature phone with no data connection. Only your server needs internet to communicate with Paystack and Africa's Talking APIs.
What happens if the USSD session times out before I can send the STK push?
The USSD session and the payment are independent. Even if the USSD session times out, the STK push (already initiated by your server) still arrives on the user phone. The user enters their PIN and the payment goes through. Your webhook receives the charge.success event and you send an SMS confirmation. The session timeout does not affect the payment. Just make sure you initiate the payment before sending the "END" response, not after.
Can I use Africa's Talking for both USSD and SMS in this architecture?
Yes, and it simplifies your integration. Africa's Talking provides USSD, SMS, and voice APIs under one account. Use their USSD API for the menu, their SMS API for payment link delivery and order confirmations, and Paystack for payment processing. This gives you two providers to manage (Africa's Talking and Paystack) instead of three or more.

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