Running Paystack and Daraja Side by Side
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.
Why Run Both Providers
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:
- Started with Daraja, now need cards. You built your product around direct M-Pesa. It works well. But now you have corporate clients who want to pay by card, or international customers, or Pesalink for high-value transactions. Daraja does not do any of that. Adding Paystack for cards while keeping Daraja for M-Pesa is cheaper than migrating everything.
- Started with Paystack, need more M-Pesa control. You launched on Paystack and it handled everything. But now your M-Pesa volume is high, and you want C2B validation (where you approve or reject each payment before it completes), your own paybill number on the customer's phone, or B2C/B2B capabilities that Paystack does not offer. Adding Daraja for M-Pesa while keeping Paystack for everything else gives you both.
- Fee optimization. Paystack charges a per-transaction fee for M-Pesa. Daraja has its own fee structure set by Safaricom. At high volume, the difference between the two fee structures adds up. Routing M-Pesa through Daraja and cards through Paystack can reduce your total payment costs.
- Redundancy. If either provider goes down, the other can handle at least some of your traffic. This is covered in detail in Building an M-Pesa Fallback When Paystack Is Unavailable.
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.
Routing Logic: Which Provider Gets Which Payment
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.
The Payment Router in Full
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.
Unified Webhook Handling
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 Across Two Providers
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:
- Transaction Status API. Query Daraja for each CheckoutRequestID to confirm the result. This works but has rate limits.
- M-Pesa statement. Download your paybill's M-Pesa statement from the Safaricom portal. Match each receipt number against the
mpesaReceiptstored in your payments table. - Account Balance API. Check your paybill balance to verify the total makes sense.
A combined daily reconciliation report should show:
- Total transactions per provider.
- Total revenue per provider.
- Transactions in your database that the provider does not recognize.
- Transactions the provider recorded that are not in your database (this usually means a webhook was missed).
- Amount mismatches.
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.
Customer Experience Considerations
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.
When This Architecture Is Worth It
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:
- Your M-Pesa transaction volume is high enough that the fee difference between Paystack and Daraja saves real money.
- You need Daraja-specific features: C2B validation, your own paybill branding, B2C/B2B APIs, or transaction reversal control.
- You need card/Pesalink support that Daraja cannot provide.
- Payment availability is critical enough to justify redundancy.
It is not worth it when:
- You process fewer than a few hundred M-Pesa transactions per month. The engineering and maintenance cost outweighs the fee savings.
- You do not need any Daraja-specific features. Paystack's M-Pesa support is good enough.
- Your team is small and the maintenance burden of two integrations is a real cost.
- You are just starting out. Ship with one provider, learn its quirks, and add the second only when you hit a concrete limitation.
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.
Key Takeaways
- ✓The most common dual-provider pattern in Kenya routes card and Pesalink payments through Paystack while sending M-Pesa charges directly through Daraja for lower fees and more control.
- ✓A payment router function decides the provider based on the payment method. Cards, Pesalink, and Airtel Money go to Paystack. M-Pesa STK Push goes to Daraja. The customer does not know or care which provider handles their payment.
- ✓Your database needs a single payments table with a provider column, a provider_ref column for the external reference, and a JSONB column for raw provider responses. One table, two sources.
- ✓Paystack and Daraja webhooks have completely different formats and authentication. Use separate endpoints, but feed both into the same confirmPayment function that updates your payments table.
- ✓Reconciliation is the ongoing cost of this architecture. You reconcile Paystack settlements from the dashboard and M-Pesa transactions from Safaricom statements against your database every day.
- ✓This architecture is worth the complexity when M-Pesa volume is high enough that the fee difference matters, or when you need Daraja-specific features like C2B validation, B2C disbursements, or your own paybill branding.
Frequently Asked Questions
- Can I route some M-Pesa payments through Paystack and others through Daraja?
- Yes. Some teams route small M-Pesa amounts through Paystack (because the absolute fee is small and Paystack is easier to reconcile) and large amounts through Daraja (where the percentage-based fee difference matters more). Your router can factor in the amount, the customer type, or any other business rule. Just make sure the routing logic is clear and documented.
- How do I handle disputes when payments go through two providers?
- Your support team needs to check the provider column in your payments table before investigating a dispute. For Paystack payments, use the Paystack dashboard or API. For Daraja payments, check your M-Pesa statement or the Daraja Transaction Status API. Build internal tools that abstract this, so the support agent enters a reference number and gets back the status from the correct provider.
- Does this architecture work outside Kenya?
- The dual-provider pattern works anywhere, but the specific providers change. In Kenya, it is Paystack plus Daraja. In Nigeria, it might be Paystack plus a direct bank integration. The architecture (router, shared data model, unified webhooks, reconciliation) is the same regardless of which two providers you are combining.
- What happens if a customer pays via Daraja but my Daraja callback URL is down?
- The customer has already paid. The money is in your paybill. But your database does not know about it. Daraja will attempt to resend the callback, but its retry behavior is less reliable than Paystack. You need a fallback: periodically query the Daraja Transaction Status API for pending payments to catch any missed callbacks. This is why reconciliation against M-Pesa statements is so important.
- Should I use Paystack Transfers or Daraja B2C for payouts?
- If you are already running Daraja for inbound M-Pesa, using Daraja B2C for payouts is logical because you already have the credentials and infrastructure. Paystack Transfers are simpler if you are already on Paystack and do not want to manage Daraja credentials for outbound transfers. Some teams use Paystack Transfers for bank payouts and Daraja B2C for M-Pesa payouts, splitting by destination type.
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