Stop Rewriting Your Paystack Integration — I Built a Unified Nigerian Fintech SDK
If you've built more than one Nigerian fintech product, you've written the same code at least twice.
Paystack integration. Then a client wants Flutterwave. Then another wants Monnify because their bank has a deal with them. Each time, you're not just swapping an API key — you're rewriting your payment initialization, your webhook handler, your transfer logic, your error handling. The shape of every response is different. The status strings are different. Even the amount units are different (Paystack uses kobo, Flutterwave uses naira, Monnify uses naira).
I got tired of it. So I built ng-pay.
What It Is
ng-pay is a TypeScript SDK that puts Paystack, Flutterwave, and Monnify behind one interface. You write your payment code once. Switching providers — or supporting multiple at once — is a one-line change.
// Change this one line to switch providers
const provider = new PaystackProvider({ secretKey: process.env.PAYSTACK_SECRET_KEY! });
// const provider = new FlutterwaveProvider({ secretKey: process.env.FLW_SECRET_KEY! });
// const provider = new MonnifyProvider({ apiKey: '...', secretKey: '...', contractCode: '...' });
// Everything else stays the same
const payment = await provider.initializePayment({ ... });
const banks = await provider.getBanks();
const account = await provider.resolveAccount('0123456789', '058');
Every adapter implements the same NgPayProvider interface, so your application code never knows or cares which provider is underneath.
The Problems It Solves
1. Amount unit confusion
Paystack expects kobo. Flutterwave expects naira. Monnify expects naira. ng-pay normalizes everything — you always work in kobo internally, and the adapters handle conversion at the boundary.
import { toKobo } from '@ng-pay/core';
// This works identically for all three providers
await provider.initializePayment({
amount: { amount: toKobo(5000), currency: 'NGN' }, // ₦5,000
customer: { email: 'customer@example.com' },
});
2. Status string normalization
Paystack returns "success". Flutterwave returns "successful". Monnify returns "PAID" or "OVERPAID". ng-pay maps all of them to a consistent PaymentStatus type:
result.status // always: 'success' | 'failed' | 'pending' | 'abandoned' | 'processing'
3. Webhook boilerplate
Every provider has a different signature header, a different signing algorithm, and a different event shape. ng-pay handles all of it:
// Paystack: x-paystack-signature header, HMAC-SHA512
// Flutterwave: verif-hash header, plain comparison
// Monnify: monnify-signature header, HMAC-SHA512
// With ng-pay — same code for all three:
if (!provider.verifyWebhook(rawBody, signature)) {
return res.status(401).send('Invalid signature');
}
const event = provider.parseWebhookEvent(JSON.parse(rawBody));
if (event.event === 'charge.success') {
await fulfillOrder(event.reference!);
}
Or use @ng-pay/middleware and skip even that:
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
ngPayWebhook({
provider: paystack,
onEvent: async (event) => {
if (event.event === 'charge.success') {
await fulfillOrder(event.reference!);
}
},
})
);
4. Error handling
Every provider throws different error shapes. ng-pay normalizes them all into typed errors you can actually handle:
import { isNgPayError, isRateLimitError } from '@ng-pay/core';
try {
await provider.initiateTransfer({ ... });
} catch (err) {
if (isRateLimitError(err)) {
await sleep(err.retryAfter! * 1000);
} else if (isNgPayError(err)) {
console.error(err.code); // 'INSUFFICIENT_BALANCE' | 'INVALID_PARAMS' | ...
console.error(err.provider); // 'paystack' | 'flutterwave' | 'monnify'
}
}
Packages
ng-pay is a monorepo with five packages:
| Package | What it does |
|---|---|
@ng-pay/core |
Shared types, HTTP client, error classes |
@ng-pay/paystack |
Paystack adapter |
@ng-pay/flutterwave |
Flutterwave adapter |
@ng-pay/monnify |
Monnify adapter |
@ng-pay/middleware |
Express, NestJS, Fastify webhook helpers |
Quick Example — Full Payment Flow
import { PaystackProvider } from '@ng-pay/paystack';
import { toKobo } from '@ng-pay/core';
const paystack = new PaystackProvider({
secretKey: process.env.PAYSTACK_SECRET_KEY!,
});
// 1. Initialize
const payment = await paystack.initializePayment({
amount: { amount: toKobo(5000), currency: 'NGN' },
customer: { email: 'customer@example.com', name: 'Adaobi Nwosu' },
callbackUrl: 'https://yourapp.com/callback',
});
// Redirect flow
redirect(payment.authorizationUrl);
// Or inline checkout
PaystackPop.setup({ key: 'pk_live_...', accessCode: payment.accessCode });
// 2. Verify
const result = await paystack.verifyPayment(payment.reference);
console.log(result.status); // 'success'
console.log(result.amount); // { amount: 500000, currency: 'NGN' }
// 3. Virtual account
const account = await paystack.createVirtualAccount({
customer: { email: 'customer@example.com', name: 'Adaobi Nwosu' },
splitCode: 'SPL_ab3defgh', // optional — split revenue
});
// 4. Payout
const recipient = await paystack.createTransferRecipient({
name: 'Adaobi Nwosu',
accountNumber: '0123456789',
bankCode: '058',
});
await paystack.initiateTransfer({
amount: { amount: toKobo(1000), currency: 'NGN' },
recipientCode: recipient.recipientCode,
description: 'Refund',
});
What's Built In
Things I didn't want to think about every project, now handled automatically:
Retry with exponential backoff — 5xx errors and timeouts are retried automatically with jitter. Rate limit responses respect the retry-after header.
Monnify OAuth — Monnify requires a token exchange before every session. ng-pay handles it transparently — you never call /auth/login yourself.
Secure credential storage — API keys are stored as non-enumerable properties. They won't appear in JSON.stringify, console.log, Object.keys, or Sentry error reports.
Timing-safe webhook verification — All providers use constant-time HMAC comparison to prevent timing oracle attacks.
Monnify environment inference — Pass MK_TEST_xxx and it uses sandbox. Pass MK_LIVE_xxx and it uses production. Ambiguous keys throw rather than silently hitting the wrong environment.
Installing
npm install @ng-pay/core @ng-pay/paystack
# or
npm install @ng-pay/core @ng-pay/flutterwave
# or
npm install @ng-pay/core @ng-pay/monnify
For webhook middleware:
npm install @ng-pay/middleware
What's Next
The roadmap includes Ghana (Hubtel), Kenya (M-Pesa), a multi-provider failover router, and Python bindings. Contributions are open — if you want to add a provider, there's a full guide in CONTRIBUTING.md.
GitHub: github.com/ProfoundLabs/ng-pay
npm: @ng-pay/core · @ng-pay/paystack · @ng-pay/flutterwave · @ng-pay/monnify
If you're building Nigerian fintech and you find this useful — or if you find something broken — open an issue or drop a comment. Would love to hear how people are using it.
Top comments (0)