Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Callback Handling for Kenyan Mobile Money

When a Kenyan customer pays via M-Pesa or Pesalink through Paystack, the charge.success webhook payload includes a channel field set to "mobile_money" or "bank_transfer." M-Pesa charges have a pending window where the status is "pay_offline" before the customer enters their PIN. The webhook arrives only after the customer completes the payment, which can be seconds to minutes after the charge was initiated. Your webhook handler must be idempotent, verify the signature, check the amount, and handle the race condition where the browser redirect and webhook arrive at nearly the same time.

Two Notification Paths: Webhook vs Redirect

When a customer pays through Paystack, your system learns about it through two separate channels. Understanding the difference is critical, especially for M-Pesa and Pesalink where the timing is less predictable than cards.

1. The webhook (server to server). Paystack sends a POST request to your webhook URL with the event data. For a successful payment, the event is charge.success. This is a server-to-server call. The customer does not see it. It happens in the background.

2. The redirect callback (browser redirect). After the customer completes payment, Paystack redirects the customer's browser to your callback URL (the URL you specified when initializing the transaction). The redirect URL includes a trxref query parameter with the transaction reference. This is a browser-side event. The customer sees the page load.

These are independent. Paystack does not wait for one to complete before sending the other. They can arrive in any order:

  • Webhook first, redirect second (most common for M-Pesa)
  • Redirect first, webhook second (can happen when the webhook delivery is delayed)
  • Both at nearly the same time (race condition territory)
  • Webhook arrives, redirect never happens (customer closes the tab after paying)
  • Redirect arrives, webhook never arrives (webhook delivery failed)

For card payments, the timing difference is usually small. The card is charged, the webhook fires, and the redirect happens, all within a second or two. For M-Pesa, the gap is wider. The customer might take 15 seconds to enter their PIN. The webhook arrives after the PIN is entered and Safaricom confirms the debit. The redirect happens when Paystack's checkout page detects the successful charge. These can be seconds or minutes apart.

The mobile_money Channel in Webhook Data

When the charge.success webhook arrives for an M-Pesa payment, the payload includes a channel field set to "mobile_money". Here is what the relevant parts of the webhook payload look like:

// charge.success webhook payload for M-Pesa
{
  "event": "charge.success",
  "data": {
    "id": 123456789,
    "domain": "live",
    "status": "success",
    "reference": "order_4582_1721472000",
    "amount": 150000,
    "currency": "KES",
    "channel": "mobile_money",
    "paid_at": "2026-07-20T14:32:18.000Z",
    "customer": {
      "email": "customer@example.com",
      "phone": "+254712345678"
    },
    "authorization": {
      "authorization_code": "AUTH_xxxxxxxxxx",
      "channel": "mobile_money",
      "bank": "MPESA"
    },
    "metadata": {
      "orderId": "4582"
    }
  }
}

Key fields to notice:

  • channel: "mobile_money" tells you this was an M-Pesa (or Airtel Money) payment.
  • authorization.bank: "MPESA" distinguishes M-Pesa from Airtel Money. For Airtel Money, this field typically shows "Airtel" or a similar identifier.
  • amount is in cents. KES 1,500 appears as 150000.
  • paid_at is when the charge completed, not when it was initiated. For M-Pesa, this is the moment the customer entered their PIN and Safaricom confirmed the debit.

For Pesalink payments, the channel is "bank_transfer":

// charge.success webhook payload for Pesalink
{
  "event": "charge.success",
  "data": {
    "id": 123456790,
    "status": "success",
    "reference": "order_4583_1721472100",
    "amount": 500000,
    "currency": "KES",
    "channel": "bank_transfer",
    "paid_at": "2026-07-20T14:35:22.000Z",
    "authorization": {
      "channel": "bank_transfer",
      "bank": "KCB"
    }
  }
}

The authorization.bank field for Pesalink shows the customer's bank (KCB, Equity, Co-op, etc.). This can be useful for analytics (which banks your customers use) but has no impact on your payment processing logic.

Timing Differences: Mobile Money vs Cards

Card payments on Paystack follow a tight sequence. The customer enters their card details, completes 3DS authentication, the card is charged, and the webhook fires. The entire process typically completes within a few seconds.

M-Pesa payments follow a looser sequence with a longer gap between initiation and completion:

  1. T+0 seconds: You call the Charge API or the customer clicks "Pay with M-Pesa" on the Paystack checkout. Paystack returns a pay_offline status.
  2. T+1 to T+5 seconds: The STK push arrives on the customer's phone. Network delays can extend this.
  3. T+5 to T+30 seconds: The customer reads the prompt and enters their M-Pesa PIN. If they are busy, distracted, or deliberating, this takes the full 30 seconds.
  4. T+30 seconds (approximately): If the customer entered their PIN, Safaricom processes the debit. If not, the push times out.
  5. T+31 to T+60 seconds (or longer): Safaricom confirms the debit to Paystack. Paystack fires the charge.success webhook to your server.

This means your webhook for an M-Pesa payment can arrive 30 to 60 seconds after you initiated the charge. During peak hours or when Safaricom is under load, it can be even longer.

Pesalink timing is different again. The customer needs to authorize the bank transfer from their banking app or USSD. This adds another variable. The authorization flow depends on the customer's bank. Some banks prompt via a mobile app notification. Others use USSD. The time between initiation and completion can range from 30 seconds to several minutes.

What this means for your code:

  • Do not show a "payment failed" screen after 10 seconds of waiting. The customer might still be entering their PIN.
  • Do not assume the webhook will arrive within any fixed time window. Build your system to handle delays.
  • Do not block the user's browser waiting for the webhook. The Paystack checkout page handles the waiting. If you are using the Charge API directly, show a "waiting for confirmation" state and poll the Verify endpoint.

Handling the Pending State

When you initiate an M-Pesa charge through the Paystack Charge API, the immediate response has a status of pay_offline. This is the pending state. The STK push has been sent, and you are waiting for the customer to act.

// Response from Charge API for M-Pesa
{
  "status": true,
  "message": "Charge attempted",
  "data": {
    "reference": "order_4582_1721472000",
    "status": "pay_offline",
    "display_text": "Please check your phone to complete payment"
  }
}

At this point, your system should:

  1. Save the pending state. Create a payment record in your database with status "pending" and the reference from Paystack. Do not mark the order as paid yet.
  2. Show the customer a waiting screen. Display the message "Check your phone for the M-Pesa prompt. Enter your PIN to complete payment." If you are using Paystack Checkout, this is handled for you.
  3. Start polling (if using the Charge API directly). Call the Verify Transaction endpoint every 5 seconds to check whether the charge has completed.
  4. Wait for the webhook. Your webhook handler will receive charge.success when the payment completes. This is the authoritative confirmation.
// Express route that initiates M-Pesa charge and handles the pending state
app.post('/api/pay/mpesa', async (req, res) => {
  const { orderId, phone, email } = req.body;

  const order = await db.orders.findByPk(orderId);
  if (!order) return res.status(404).json({ error: 'Order not found' });

  const reference = `order_${orderId}_${Date.now()}`;

  // Initiate charge
  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,
      amount: order.amountInCents,
      currency: 'KES',
      reference,
      mobile_money: { phone, provider: 'mpesa' },
      metadata: { orderId },
    }),
  });

  const chargeData = await chargeRes.json();

  if (!chargeData.status) {
    return res.status(400).json({ error: chargeData.message });
  }

  // Save pending payment
  await db.payments.create({
    orderId,
    reference,
    amount: order.amountInCents,
    currency: 'KES',
    channel: 'mobile_money',
    status: 'pending',
    initiatedAt: new Date(),
  });

  // Return the pending status to the frontend
  res.json({
    status: 'pending',
    reference,
    message: chargeData.data.display_text || 'Check your phone for the M-Pesa prompt',
  });
});

The frontend can then poll a status endpoint on your server (which in turn calls Paystack's Verify endpoint) to update the UI when the payment completes. Or it can just wait for a WebSocket notification from your server when the webhook arrives.

Building the Webhook Handler

Here is a complete webhook handler that correctly processes M-Pesa, Pesalink, and card payments, distinguishing between channels and handling each appropriately.

const crypto = require('crypto');
const express = require('express');

const app = express();

// Important: Use express.json() to get the raw body for signature verification.
// If you use a body parser that modifies the body, the signature check will fail.
app.post('/webhooks/paystack', express.json(), async (req, res) => {
  // Step 1: Verify signature immediately
  const hash = crypto
    .createHmac('sha512', process.env.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. Process asynchronously.
  res.status(200).send('OK');

  // Step 3: Process the event
  const event = req.body;

  try {
    switch (event.event) {
      case 'charge.success':
        await handleChargeSuccess(event.data);
        break;
      case 'charge.failed':
        await handleChargeFailed(event.data);
        break;
      // Handle other events as needed
    }
  } catch (err) {
    console.error('Webhook processing error:', err);
    // Log the error but do not throw. We already returned 200.
    // The reconciliation job will catch anything we missed.
  }
});

async function handleChargeSuccess(data) {
  const { reference, amount, currency, channel, paid_at } = data;

  // Step 1: Idempotency check. Have we already processed this reference?
  const existing = await db.payments.findOne({ where: { reference } });

  if (existing && existing.status === 'paid') {
    console.log(`Already processed: ${reference}`);
    return; // Already handled. No double grant.
  }

  // Step 2: Verify with Paystack (belt and suspenders)
  const verifyRes = await fetch(
    `https://api.paystack.co/transaction/verify/${reference}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
      },
    }
  );

  const verified = await verifyRes.json();

  if (verified.data.status !== 'success') {
    console.error(`Verification failed for ${reference}: ${verified.data.status}`);
    return;
  }

  // Step 3: Verify amount matches what we expected
  if (existing && existing.amount !== amount) {
    console.error(
      `Amount mismatch for ${reference}: expected ${existing.amount}, got ${amount}`
    );
    // Flag for manual review, do not grant value
    await db.payments.update(
      { status: 'amount_mismatch', paystackAmount: amount },
      { where: { reference } }
    );
    return;
  }

  // Step 4: Determine channel-specific handling
  const channelInfo = {
    channel,
    bank: data.authorization?.bank || 'unknown',
    provider: channel === 'mobile_money' ? data.authorization?.bank : channel,
  };

  // Step 5: Update payment record
  if (existing) {
    await db.payments.update(
      {
        status: 'paid',
        channel: channelInfo.channel,
        provider: channelInfo.provider,
        paidAt: paid_at,
        paystackId: data.id,
      },
      { where: { reference } }
    );
  } else {
    // Payment not in our DB (missed the initiation somehow)
    await db.payments.create({
      reference,
      amount,
      currency,
      channel: channelInfo.channel,
      provider: channelInfo.provider,
      status: 'paid',
      paidAt: paid_at,
      paystackId: data.id,
      reconciledFromWebhook: true,
    });
  }

  // Step 6: Grant value (fulfill the order)
  const orderId = data.metadata?.orderId || extractOrderFromReference(reference);
  if (orderId) {
    await fulfillOrder(orderId, reference, channelInfo);
  }

  // Step 7: Channel-specific post-processing
  if (channelInfo.channel === 'mobile_money') {
    console.log(
      `M-Pesa payment confirmed: ${reference}, provider: ${channelInfo.provider}`
    );
    // M-Pesa specific: send SMS confirmation to the phone number
    if (data.customer?.phone) {
      await sendSmsConfirmation(data.customer.phone, amount, reference);
    }
  } else if (channelInfo.channel === 'bank_transfer') {
    console.log(
      `Pesalink payment confirmed: ${reference}, bank: ${channelInfo.bank}`
    );
  } else if (channelInfo.channel === 'card') {
    console.log(`Card payment confirmed: ${reference}`);
  }
}

async function handleChargeFailed(data) {
  const { reference, gateway_response } = data;

  await db.payments.update(
    {
      status: 'failed',
      failureReason: gateway_response,
      failedAt: new Date(),
    },
    { where: { reference } }
  );

  console.log(`Payment failed: ${reference}, reason: ${gateway_response}`);
}

A few things to note about this handler:

  • It returns 200 immediately, before processing. This is critical. Paystack expects a 200 response within a few seconds. If your processing takes longer, Paystack may retry the webhook, leading to duplicate deliveries.
  • The idempotency check prevents double-granting. If the webhook fires twice (Paystack retry, or a race condition), the second call sees the payment is already "paid" and exits.
  • It verifies with the Paystack API after receiving the webhook. This is the belt-and-suspenders approach. Even if someone fakes a webhook, the verify call confirms the truth.
  • It stores the channel and provider for later use in reconciliation, analytics, and customer support.

The Race Condition: Webhook vs Redirect

The most common source of double-granting in Paystack integrations is the race condition between the webhook and the redirect callback. Here is how it happens with M-Pesa:

  1. Customer enters their M-Pesa PIN. Safaricom debits their wallet.
  2. Paystack's checkout page detects the success and redirects the customer's browser to your callback URL.
  3. At the same time, Paystack fires the charge.success webhook to your server.
  4. Your redirect handler receives the request, calls the Verify API, sees "success," and grants value (marks the order as paid, sends confirmation email).
  5. Milliseconds later, your webhook handler receives the event, sees "success," and also grants value.
  6. The customer gets double value: two confirmation emails, two inventory deductions, or two subscription activations.

The fix is the idempotency check shown in the webhook handler above. Both the redirect handler and the webhook handler should check whether the payment has already been processed before granting value.

// Redirect callback handler
app.get('/payment/callback', async (req, res) => {
  const reference = req.query.trxref || req.query.reference;

  if (!reference) {
    return res.redirect('/payment/error?reason=no_reference');
  }

  // Verify with Paystack
  const verifyRes = await fetch(
    `https://api.paystack.co/transaction/verify/${reference}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
      },
    }
  );

  const verified = await verifyRes.json();

  if (verified.data.status !== 'success') {
    return res.redirect(`/payment/failed?reference=${reference}`);
  }

  // Idempotency: check if the webhook already handled this
  const payment = await db.payments.findOne({ where: { reference } });

  if (payment && payment.status === 'paid') {
    // Webhook already processed it. Just show the success page.
    return res.redirect(`/payment/success?reference=${reference}`);
  }

  // Process the payment (same logic as webhook handler)
  await processPayment(reference, verified.data);

  res.redirect(`/payment/success?reference=${reference}`);
});

The key principle: only one code path should grant value. If both the webhook and the redirect can process the payment, the first one wins and the second one sees "already paid" and skips. Use a database transaction or an atomic update to prevent the two from both seeing "pending" at the same time.

For a deeper look at this specific problem, see The Double-Grant Bug: Webhook and Callback Both Firing and Race Conditions Between Paystack Webhooks and Redirect Callbacks.

What to Store From the Webhook

For every M-Pesa or Pesalink payment you process, store these fields from the webhook payload. You will need them for reconciliation, customer support, and debugging.

Field Where to find it Why you need it
Reference data.reference Primary matching key for reconciliation
Paystack ID data.id Unique identifier in Paystack's system
Amount (cents) data.amount Verify against expected amount
Currency data.currency Should always be KES for Kenya
Channel data.channel mobile_money, bank_transfer, or card
Provider/Bank data.authorization.bank MPESA, Airtel, KCB, Equity, etc.
Paid at data.paid_at When the payment completed
Customer email data.customer.email Customer identification
Customer phone data.customer.phone M-Pesa number used for payment
Fees data.fees Paystack fee for this transaction
Gateway response data.gateway_response Human-readable result from the payment provider
Metadata data.metadata Your custom data (order ID, product ID, etc.)

Also store the raw webhook payload in a separate audit log table. When something goes wrong (and with payments, something always eventually goes wrong), the raw payload is your evidence for what Paystack actually sent you. See Storing Raw Paystack Webhook Payloads for Audit.

For the full picture of Paystack webhook handling beyond Kenya-specific channels, see Why Webhooks Beat Callbacks for Paystack Payments.

Key Takeaways

  • M-Pesa payments through Paystack use the "mobile_money" channel. Pesalink payments use the "bank_transfer" channel. Your webhook handler can read the channel field to determine how the customer paid.
  • M-Pesa charges have a longer pending window than card payments. The customer must enter their PIN on their phone, which can take anywhere from a few seconds to 30 seconds (or longer if there are network delays). Your system must handle this pending state gracefully.
  • The webhook for an M-Pesa charge may arrive minutes after you initiated the charge. Do not assume instant confirmation. Build your UI to show a "waiting for confirmation" state.
  • The redirect callback (browser redirect after payment) and the webhook (server-to-server notification) are two different things. They can arrive in any order. Your system must handle both without granting value twice.
  • Always verify the x-paystack-signature header, check the amount and currency, and confirm the transaction via the Verify API before granting value. These checks are not optional for any channel, but they are especially important for mobile money where the timing is unpredictable.
  • Store the channel in your payments table. During reconciliation and customer support, knowing whether a payment came through M-Pesa, card, or Pesalink helps you debug issues faster.

Frequently Asked Questions

How do I know if a Paystack payment was made via M-Pesa or card?
Check the channel field in the webhook payload or the Verify Transaction response. For M-Pesa, channel is "mobile_money" and authorization.bank is "MPESA." For cards, channel is "card." For Pesalink, channel is "bank_transfer." For Airtel Money, channel is "mobile_money" and authorization.bank shows the Airtel identifier.
Why does my M-Pesa webhook arrive so long after I initiated the charge?
M-Pesa payments are asynchronous. After Paystack sends the STK push, the customer needs time to find their phone, read the prompt, and enter their PIN. This can take anywhere from a few seconds to 30 seconds. Then Safaricom processes the debit and notifies Paystack, which sends the webhook to you. Delays on any leg of this chain extend the total time. During Safaricom peak hours, the delay can be even longer.
Should I handle the redirect callback or the webhook for granting value?
Both should be able to grant value, but only one should actually do it for any given transaction. Use an idempotency check (look up the payment by reference and check if it is already marked as paid). The first path to run wins. The second path sees the payment is already handled and skips. Relying only on the webhook is safer (server-to-server), but the redirect gives the customer a faster experience.
What if the webhook arrives but my server is down?
Paystack retries webhook deliveries when your server returns a non-200 response or is unreachable. The retries happen with increasing delays. If all retries fail, the webhook is lost. Your reconciliation sweeper job (polling the Verify endpoint for pending transactions) catches these cases. Build the sweeper from day one.
Do Pesalink webhooks use the same charge.success event as M-Pesa?
Yes. Both M-Pesa and Pesalink successful payments trigger the charge.success webhook event. The difference is in the channel field: "mobile_money" for M-Pesa and "bank_transfer" for Pesalink. Your webhook handler processes both with the same logic. Store the channel so you can distinguish them in reports and reconciliation.

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