To run Paystack and Daraja side by side, build a payment router that sends card and Pesalink charges to Paystack and M-Pesa charges to Daraja. Use a single payments table with a provider column. Handle webhooks from both providers at separate endpoints but funnel results into the same confirmation logic. Reconcile daily against both Paystack settlements and M-Pesa statements.
Most teams do not set out to run two payment providers. They start with one, and then they hit a limitation that forces them to add the other.
The usual scenarios:
If none of these apply to you, do not add complexity for the sake of it. Paystack alone handles M-Pesa, cards, Pesalink, Airtel Money, and Apple Pay through one integration. For most early-stage products, that is enough. The dual-provider architecture is for teams that have hit a specific limitation or cost threshold.
The central piece of a dual-provider architecture is the router. It takes a payment request, looks at the payment method, and sends it to the right provider.
The most common routing table for Kenya:
| Payment Method | Provider | Why |
|---|---|---|
| M-Pesa STK Push | Daraja | Lower fees, C2B validation, own paybill |
| Visa / Mastercard | Paystack | Only option (Daraja does not handle cards) |
| Pesalink | Paystack | Only option (Daraja does not handle bank transfers) |
| Airtel Money | Paystack | Daraja is Safaricom-only |
| Apple Pay | Paystack | Only option |
| B2C Disbursements | Daraja | Direct M-Pesa B2C, or Paystack Transfers |
In code, the router is a function that takes the payment method and returns the provider:
// paymentRouter.js
function selectProvider(paymentMethod) {
switch (paymentMethod) {
case 'mpesa':
return 'daraja';
case 'card':
case 'pesalink':
case 'airtel_money':
case 'apple_pay':
case 'bank_transfer':
return 'paystack';
default:
throw new Error(`Unknown payment method: ${paymentMethod}`);
}
}
This is deliberately simple. You could add more logic later (route M-Pesa to Paystack below a certain amount, or route to Daraja only when the paybill is available), but start with a clean split based on payment method. Complexity here means bugs during checkout, and bugs during checkout mean lost revenue.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Here is a complete payment router that handles both providers. Each provider has its own charge function, but the router presents a unified interface to the rest of your application.
// paymentRouter.js
const crypto = require('crypto');
// Paystack charge implementation
async function chargeViaPaystack({ method, phone, email, amount, reference }) {
if (method === 'mpesa' || method === 'airtel_money') {
// Mobile money through Paystack Charge API
const providerName = method === 'mpesa' ? 'mpesa' : 'airtel';
const res = 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, // in cents
currency: 'KES',
reference,
mobile_money: { phone, provider: providerName },
}),
});
const data = await res.json();
return { providerRef: data.data?.reference, raw: data };
}
// Card, Pesalink, bank transfer: use Transaction Initialize
const res = 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,
amount,
currency: 'KES',
reference,
channels: [method === 'pesalink' ? 'bank_transfer' : method],
}),
});
const data = await res.json();
return {
providerRef: data.data?.reference,
authorizationUrl: data.data?.authorization_url,
raw: data,
};
}
// Daraja STK Push implementation
async function chargeViaDaraja({ phone, amount, reference }) {
// Step 1: Get OAuth token
const auth = Buffer.from(
`${process.env.DARAJA_CONSUMER_KEY}:${process.env.DARAJA_CONSUMER_SECRET}`
).toString('base64');
const tokenRes = await fetch(
'https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials',
{ headers: { Authorization: `Basic ${auth}` } }
);
const { access_token } = await tokenRes.json();
// Step 2: Initiate STK Push
const timestamp = new Date()
.toISOString()
.replace(/[-T:.Z]/g, '')
.slice(0, 14);
const password = Buffer.from(
`${process.env.DARAJA_SHORTCODE}${process.env.DARAJA_PASSKEY}${timestamp}`
).toString('base64');
const stkRes = await fetch(
'https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest',
{
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
BusinessShortCode: process.env.DARAJA_SHORTCODE,
Password: password,
Timestamp: timestamp,
TransactionType: 'CustomerPayBillOnline',
Amount: Math.round(amount / 100), // Daraja expects whole KES, not cents
PartyA: phone,
PartyB: process.env.DARAJA_SHORTCODE,
PhoneNumber: phone,
CallBackURL: `${process.env.BASE_URL}/webhooks/daraja/stk`,
AccountReference: reference,
TransactionDesc: `Payment ${reference}`,
}),
}
);
const data = await stkRes.json();
return {
providerRef: data.CheckoutRequestID,
raw: data,
};
}
// Unified charge function
async function processPayment({ method, phone, email, amount, reference }) {
const provider = selectProvider(method);
let result;
if (provider === 'daraja') {
result = await chargeViaDaraja({ phone, amount, reference });
} else {
result = await chargeViaPaystack({ method, phone, email, amount, reference });
}
return { provider, ...result };
}
module.exports = { processPayment, selectProvider };
Notice the amount conversion. Paystack expects amounts in cents (KES 1,500 = 150000). Daraja expects whole shillings (KES 1,500 = 1500). This is exactly the kind of detail that causes off-by-100x bugs. The router handles the conversion so the rest of your code always works in cents.
Two providers means two webhook endpoints. But both endpoints should funnel into the same payment confirmation logic.
// Shared confirmation function
async function confirmPayment({ providerRef, provider, status, meta }) {
const payment = await db.query(
'SELECT * FROM payments WHERE provider_ref = $1 AND provider = $2',
[providerRef, provider]
);
if (!payment) {
console.error(`No payment found for ${provider} ref: ${providerRef}`);
return;
}
if (payment.status === 'success') {
// Already confirmed. Idempotent handling.
return;
}
await db.query(
'UPDATE payments SET status = $1, confirmed_at = $2, provider_meta = $3 WHERE id = $4',
[status, new Date(), JSON.stringify(meta), payment.id]
);
if (status === 'success') {
// Trigger downstream actions: send receipt, update order, etc.
await onPaymentConfirmed(payment);
}
}
// Paystack webhook endpoint
app.post('/webhooks/paystack', express.json(), (req, res) => {
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.status(401).send('Unauthorized');
}
const event = req.body;
if (event.event === 'charge.success') {
confirmPayment({
providerRef: event.data.reference,
provider: 'paystack',
status: 'success',
meta: event.data,
});
}
res.status(200).send('OK');
});
// Daraja STK Push callback endpoint
app.post('/webhooks/daraja/stk', express.json(), (req, res) => {
const callback = req.body?.Body?.stkCallback;
if (!callback) return res.status(400).json({ ResultCode: 1 });
const checkoutRequestId = callback.CheckoutRequestID;
const resultCode = callback.ResultCode;
const items = callback.CallbackMetadata?.Item || [];
const status = resultCode === 0 ? 'success' : 'failed';
confirmPayment({
providerRef: checkoutRequestId,
provider: 'daraja',
status,
meta: {
resultCode,
resultDesc: callback.ResultDesc,
mpesaReceipt: items.find(i => i.Name === 'MpesaReceiptNumber')?.Value,
amount: items.find(i => i.Name === 'Amount')?.Value,
phone: items.find(i => i.Name === 'PhoneNumber')?.Value,
},
});
res.json({ ResultCode: 0, ResultDesc: 'Accepted' });
});
The pattern is: each webhook endpoint handles its own format and authentication, extracts the relevant fields, and calls confirmPayment with a normalized payload. The confirmPayment function does not know or care which provider it came from. It just updates the database and triggers downstream actions.
This is important because your downstream logic (sending receipts, fulfilling orders, updating subscription status) should be provider-agnostic. A successful payment is a successful payment, whether it came through Paystack or Daraja. If you start putting provider-specific branches in your order fulfillment code, you are headed for maintenance problems.
Reconciliation is where dual-provider architecture extracts its real cost. Every day, you need to verify that the payments in your database match what the providers say happened.
Paystack reconciliation. Paystack provides settlement reports in the dashboard. Each settlement batch lists the transactions included. You can also use the GET /transaction/verify/:reference endpoint to verify individual transactions. The flow is: pull today's successful Paystack payments from your database, verify each against the Paystack API, and flag any mismatches.
Daraja reconciliation. Daraja is less tidy. You have a few options:
mpesaReceipt stored in your payments table.A combined daily reconciliation report should show:
Some teams automate this fully. Others run it semi-manually, pulling reports from both dashboards and comparing in a spreadsheet. The right level of automation depends on your transaction volume. At 50 transactions a day, a spreadsheet is fine. At 5,000, you need an automated job.
Your customers should not know or care that you run two payment providers. The checkout experience should feel like one system.
Payment method selection. Present payment options by method (M-Pesa, Card, Pesalink), not by provider. The customer picks "Pay with M-Pesa" and your backend routes it to Daraja. They pick "Pay with Card" and your backend routes it to Paystack. The provider is an implementation detail.
STK Push experience. Whether the STK push comes from Paystack or Daraja, the customer experience on their phone is very similar. The main difference: the paybill number or business name shown in the push. With Paystack, it shows a Paystack-related shortcode. With Daraja, it shows your own paybill. Some customers notice this. Most do not.
Card checkout. For card payments via Paystack, you can use Paystack's hosted checkout (redirect the customer to Paystack's page) or the inline popup. Either way, this is a Paystack-only flow. There is no Daraja equivalent.
Receipts and confirmations. Send the same receipt format regardless of which provider processed the payment. The receipt should show the amount, the payment method, your reference number, and a timestamp. It should not say "Processed by Paystack" or "Processed by Daraja." That is your internal concern.
Refunds. This is where dual providers get tricky for customer support. A refund for a Paystack payment uses the Paystack Refund API. A refund for a Daraja payment uses the B2C API to send money back to the customer's M-Pesa. Your support team needs to know which provider processed the original payment before initiating a refund. Build a tool that looks up the payment and shows them the provider, or build a unified refund function that handles both.
Customer support queries. When a customer contacts support saying "I paid but did not receive my order," your support team needs to search across both providers. Build your admin tools to search by phone number, reference, or amount across the single payments table. Do not make support agents check two separate dashboards.
Running two payment providers is not free. You maintain two integrations, two sets of credentials, two webhook handlers, and a reconciliation process that spans both. Every payment feature (refunds, receipts, reports, subscriptions) needs to work with both providers.
It is worth it when:
It is not worth it when:
If you are on the fence, start with Paystack alone. It covers M-Pesa, cards, Pesalink, Airtel Money, and Apple Pay through one API. Add Daraja later if and when you have a specific reason. The architecture described in this article is the destination, not the starting point.
For related reading, see Paystack in Kenya: M-Pesa, Pesalink and the Daraja Question for the broader context, and Migrating from Direct Daraja to Paystack or Migrating from Paystack to Direct Daraja if you are considering moving in one direction rather than running both.
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