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.
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:
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.
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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:
pay_offline status.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:
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:
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.
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:
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:
charge.success webhook to your server.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.
Pesalink payments through Paystack use the bank_transfer channel. The callback and webhook behavior is similar to M-Pesa, with a few differences:
Longer authorization window. Pesalink requires the customer to authorize the transfer from their bank. Depending on the bank, this could be via a mobile app push notification, USSD, or even a browser redirect to the bank's authorization page. The time between initiation and completion can be longer than M-Pesa's 30-second window.
Bank identification. The webhook payload includes the customer's bank name in the authorization.bank field. This is useful for analytics but does not change your processing logic.
Higher amounts. Pesalink is often used for higher-value transactions because M-Pesa has per-transaction limits. If you are building a system that handles large payments (rent, school fees, wholesale orders), the Pesalink webhook for a single transaction might represent a significant amount of money. Extra care with verification and idempotency is warranted.
Same webhook handler. You do not need a separate webhook handler for Pesalink. The same charge.success handler that processes M-Pesa payments also processes Pesalink payments. The only difference is the channel field in the payload. Your handler should store the channel for each payment so you can filter and report on them separately.
// Distinguishing channels in your handler
if (data.channel === 'mobile_money') {
// M-Pesa or Airtel Money
const mobileProvider = data.authorization?.bank; // "MPESA" or "Airtel"
console.log(`Mobile money payment via ${mobileProvider}`);
} else if (data.channel === 'bank_transfer') {
// Pesalink
const customerBank = data.authorization?.bank; // "KCB", "Equity", etc.
console.log(`Bank transfer via ${customerBank}`);
} else if (data.channel === 'card') {
// Visa or Mastercard
const cardType = data.authorization?.card_type; // "visa", "mastercard"
console.log(`Card payment via ${cardType}`);
}
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.
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