DEV Community

Jesse Muuya
Jesse Muuya

Posted on

How to Handle M-Pesa STK Push Timeouts & Webhook Verification in Node.js (TypeScript)

Integrating African payment gateways like M-Pesa, Paystack, and Flutterwave into a Node.js backend comes with three recurring failure points:

M-Pesa STK Push Timeouts: Users enter their PIN late, network requests drop, and the callback never reaches your server.

Inconsistent Phone Formatting: Accepts 0712..., 254712..., or +254712..., breaking gateway payloads.

Payload Spoofing: Accepting unvalidated webhook bodies directly into your database.

Here is how to structure a resilient, zero-hosting-overhead TypeScript solution for these edge cases.

1. Normalizing Local Phone Numbers
M-Pesa and regional gateways strictly require E.164 string formats (+254XXXXXXXXX). Hand-rolling regex during API calls leads to silent runtime crashes.

Use a dedicated normalizer function before sending your STK push payload:

TypeScript

export function normalizeKenyanPhone(phone: string): string {
  // Strip spaces, dashes, and non-numeric characters except leading +
  let cleaned = phone.replace(/[^\d+]/g, '');

  if (cleaned.startsWith('0')) {
    cleaned = `+254${cleaned.slice(1)}`;
  } else if (cleaned.startsWith('254')) {
    cleaned = `+${cleaned}`;
  } else if (!cleaned.startsWith('+254')) {
    throw new Error(`Invalid local phone number format: ${phone}`);
  }

  return cleaned;
}
Enter fullscreen mode Exit fullscreen mode

2. Strict Runtime Webhook Validation (Zod)
Never trust gateway request bodies without checking payload shapes at runtime. Using Zod allows you to validate raw JSON bodies before touch points with database ORMs like Prisma.

TypeScript

// validation-shapes.ts
import { z } from 'zod';

export const PaystackWebhookSchema = z.object({
  event: z.string(),
  data: z.object({
    id: z.number(),
    status: z.enum(['success', 'failed', 'abandoned']),
    reference: z.string(),
    amount: z.number(),
    currency: z.string().default('KES'),
    metadata: z.object({
      tenantId: z.string(),
      userId: z.string().optional(),
    }).passthrough(),
    customer: z.object({
      email: z.string(),
    }),
  }),
});

export type PaystackWebhookPayload = z.infer<typeof PaystackWebhookSchema>;
Enter fullscreen mode Exit fullscreen mode

3. Recovering from Dropped Webhooks
When M-Pesa callbacks fail due to network timeouts, the transaction remains stuck in a PENDING state. The fix is a non-blocking background reconciliation task running on a schedule (e.g., via node-cron or serverless cron triggers) to query the provider's query API directly:

TypeScript

// Example background polling logic
async function reconcilePendingTransactions() {
  const pendingTx = await db.transaction.findMany({ where: { status: 'PENDING' } });

  for (const tx of pendingTx) {
    const statusResponse = await queryMpesaExpressStatus(tx.checkoutRequestId);

    if (statusResponse.ResultCode === '0') {
      await db.transaction.update({
        where: { id: tx.id },
        data: { status: 'SUCCESS' },
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Need the Production Microservice Boilerplate?
If you don't want to build the entire infrastructure from scratch, I've packaged a production-ready, framework-agnostic TypeScript engine containing:

Raw-buffer webhook signature verification (Paystack, Flutterwave, M-Pesa).

Pre-built Prisma database schemas with multi-tenant support.

Self-healing reconciliation cron jobs for timed-out callbacks.

React/Vite PWA checkout components.

👉 Get the African Payment Gateways Engine ($19–$99)
👉 View the Lite GitHub Demo

Top comments (0)