A plain-language guide to everything the library does and how to use it.
- What this library is
- Before you start
- Installation
- A note on amounts — read this first
- Basic setup
- Collecting a payment (Paystack)
- Collecting a payment (Flutterwave)
- Using both providers at once
- Verifying a payment
- Checking that the right amount was paid
- Preventing duplicate fulfilment
- Issuing refunds
- Webhooks — getting notified when a payment happens
- Understanding errors
- Logging
- Configuration reference
- TypeScript types reference
- Frequently asked questions
payment-africa is a TypeScript/JavaScript library that lets you accept payments through Paystack and Flutterwave — two of the most widely-used payment providers in Africa — using one consistent, well-typed API.
Without this library you would need to:
- Read and implement two separate API documentations.
- Handle two different response shapes, status names, error formats, and signature styles.
- Build your own retry logic, idempotency handling, and amount-verification separately for each.
With payment-africa you write your code once. The library handles all the differences under the hood.
What you can do with it:
| Feature | What it means |
|---|---|
| Initialize a payment | Create a payment session and get a URL to send the customer to |
| Verify a payment | Confirm a payment actually happened — server-side, not just based on the customer's callback |
| Refund a payment | Send money back to the customer |
| Receive webhooks | Get notified instantly when a payment succeeds, fails, or changes |
| Prevent double-charging | Cache results so the same event is only processed once |
| Reconcile amounts | Catch cases where the customer paid the wrong amount |
You will need:
- Node.js 18 or later. Check with
node --version. - A Paystack account (if you want to use Paystack). Get your API keys at paystack.com/account/settings. You want the Secret Key.
- A Flutterwave account (if you want to use Flutterwave). Get your API keys at dashboard.flutterwave.com. You want the Secret Key.
Start with test keys. Both providers give you test credentials that let you simulate payments without real money. Your test key looks like
sk_test_...on Paystack andFLWSECK_TEST-...on Flutterwave. Only switch to live keys when you are ready to charge real customers.
npm install payment-africaThat is the only package you need. There are no required peer dependencies.
If you use Redis for idempotency (covered later), you will install your Redis client separately — but that is optional.
This is the most common source of bugs in payment code. Please read it.
payment-africa uses minor units everywhere. That means:
- ₦500.00 is written as
50000(kobo) - GHS 10.00 is written as
1000(pesewa) - $1.00 is written as
100(cents)
You always multiply by 100 to go from a human-readable amount to what the library expects. You always divide by 100 to go back.
Why? Because floating-point numbers are unreliable for money. 0.1 + 0.2 in JavaScript is 0.30000000000000004, not 0.3. Using integers avoids this entirely.
// wrong use
const amount = 500.0;
// Correct use
const amount = 50000; // This is ₦500.00This is the same convention used by Stripe, Paystack's own API, and most modern payment libraries.
import { Paystack } from 'payment-africa';
const paystack = new Paystack({
secretKey: process.env.PAYSTACK_SECRET_KEY, // never hardcode this
});import { Flutterwave } from 'payment-africa';
const flutterwave = new Flutterwave({
secretKey: process.env.FLW_SECRET_KEY,
});import { PaymentAfrica } from 'payment-africa';
const client = new PaymentAfrica({
paystack: {
secretKey: process.env.PAYSTACK_SECRET_KEY,
},
flutterwave: {
secretKey: process.env.FLW_SECRET_KEY,
},
});The PaymentAfrica class is a single entry point that wraps both providers. You can access each provider at client.paystack and client.flutterwave.
On secret keys and environment variables: Never paste your secret keys directly into your code. Use environment variables. In development, put them in a
.envfile (and add.envto your.gitignore). In production, use your hosting platform's secret management (Heroku config vars, Railway variables, AWS Secrets Manager, etc.).
There are two steps to every payment:
- Initialize — you tell Paystack about the payment and get back a URL to send the customer to.
- Verify — after the customer pays (or tries to), Paystack redirects them back to your site. You call verify to confirm the payment actually happened.
Never skip step 2. The redirect URL can be faked by an attacker. Always verify server-side.
import { Paystack } from 'payment-africa';
const paystack = new Paystack({
secretKey: process.env.PAYSTACK_SECRET_KEY,
});
const result = await paystack.initializeTransaction({
reference: 'order_' + Date.now(), // a unique ID you make up for this payment
amount: 50000, // ₦500.00 in kobo
currency: 'NGN',
email: 'customer@example.com',
callbackUrl: 'https://yoursite.com/payment/callback',
});
console.log(result.authorizationUrl);
// → "https://checkout.paystack.com/..." — send the customer here for checkoutWhat each field means:
| Field | Required | Description |
|---|---|---|
reference |
Yes | A unique string you create to identify this specific payment. Store it in your database. You will use it to verify the payment later. |
amount |
Yes | The amount in minor units (kobo for NGN). Must be a whole number. |
currency |
Yes | ISO currency code. NGN for naira, GHS for cedi, KES for shilling, USD for US dollar, etc. |
email |
Yes | The customer's email address. |
callbackUrl |
No | Where Paystack redirects the customer after they pay. If you don't provide this, Paystack uses the one set in your dashboard. |
channels |
No | Limit which payment methods the customer can use. See the list below. |
metadata |
No | Any extra data you want to attach, like an order ID. You get it back when you verify. |
const result = await paystack.initializeTransaction({
reference: 'order_123',
amount: 50000,
currency: 'NGN',
email: 'customer@example.com',
channels: ['card', 'bank_transfer', 'ussd'],
// Only show card, bank transfer, and USSD options to the customer
});Available channels: card, bank, ussd, qr, mobile_money, bank_transfer, eft
After initializeTransaction succeeds, redirect your customer to result.authorizationUrl. Paystack's checkout page handles the rest.
When the customer finishes (whether they paid or not), Paystack redirects them to your callbackUrl with a reference query parameter.
https://yoursite.com/payment/callback?reference=order_123&trxref=order_123
Use that reference to verify the payment (see section 9).
The flow is identical from your perspective — initialize, redirect, verify. The library handles the differences internally.
import { Flutterwave } from 'payment-africa';
const flutterwave = new Flutterwave({
secretKey: process.env.FLW_SECRET_KEY,
});
const result = await flutterwave.initializeTransaction({
reference: 'order_' + Date.now(),
amount: 50000, // still kobo/minor units — the library converts for you
currency: 'NGN',
email: 'customer@example.com',
callbackUrl: 'https://yoursite.com/payment/callback',
});
console.log(result.authorizationUrl);
// → "https://checkout.flutterwave.com/..." — send the customer here for paymentFlutterwave's API actually uses major units (naira, not kobo). The library converts automatically. You always pass minor units to
payment-africaregardless of the provider.
After the customer pays, Flutterwave redirects them to your callbackUrl with a tx_ref query parameter that matches your reference.
If you have both providers configured, use the PaymentAfrica facade so you don't have to track which provider each payment used.
Add a short prefix to your references so the library knows which provider to route to:
import { PaymentAfrica } from 'payment-africa';
const client = new PaymentAfrica({
paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY },
flutterwave: { secretKey: process.env.FLW_SECRET_KEY },
referencePrefixes: {
paystack: 'ps_',
flutterwave: 'flw_',
},
});Now when you initialize a payment, specify which provider you want:
// This payment goes through Paystack
// The library automatically adds 'ps_' to your reference
const psPayment = await client.initializeTransaction(
{
reference: 'order_123', // becomes 'ps_order_123'
amount: 50000,
currency: 'NGN',
email: 'customer@example.com',
},
{ provider: 'paystack' },
);
// This one goes through Flutterwave
// The library automatically adds 'flw_' to your reference
const fwPayment = await client.initializeTransaction(
{
reference: 'order_456', // becomes 'flw_order_456'
amount: 50000,
currency: 'NGN',
email: 'customer@example.com',
},
{ provider: 'flutterwave' },
);When you later verify a payment, the library reads the prefix and knows which provider to ask:
// Library sees 'ps_' asks Paystack
await client.verifyTransaction('ps_order_123');
// Library sees 'flw_' asks Flutterwave
await client.verifyTransaction('flw_order_456');You never have to remember which provider handled which payment.
If you only configure one provider, you don't need prefixes or hints. The library uses whichever provider is configured:
const client = new PaymentAfrica({
paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY },
// no flutterwave — that's fine
});
// The library uses Paystack automatically
const payment = await client.initializeTransaction({
reference: 'order_123',
amount: 50000,
currency: 'NGN',
email: 'customer@example.com',
});After the customer is redirected back to your site, you get their reference from the URL and verify the payment server-side.
const reference = req.query.reference; // from the callback URL
const transaction = await paystack.verifyTransaction(reference);
if (transaction.status === 'success') {
// Payment confirmed — fulfil the order
await fulfillOrder(transaction.reference, transaction.amount);
} else if (transaction.status === 'failed') {
// Payment failed
await markOrderFailed(transaction.reference);
} else {
// Still pending — the customer may still be paying
// Don't fulfil yet; you'll know via webhook when it completes
}The result is a NormalizedTransaction object with these fields:
| Field | Type | Description |
|---|---|---|
provider |
string |
Which provider processed this: 'paystack' or 'flutterwave' |
reference |
string |
The reference you provided at initialize time |
status |
string |
The payment status (see table below) |
amount |
number |
Amount in minor units (kobo, pesewa, etc.) |
currency |
string |
Currency code ('NGN', 'GHS', etc.) |
paidAt |
string |
ISO timestamp of when the payment was made |
customer.email |
string |
Customer's email |
customer.firstName |
string |
Customer's first name (if available) |
customer.lastName |
string |
Customer's last name (if available) |
processorResponse |
string |
The payment network's response message (e.g. "Approved") |
metadata |
object |
Whatever you passed in metadata when you initialized |
raw |
object |
The complete original response from the provider (for debugging) |
| Status | Meaning |
|---|---|
success |
Payment confirmed. Safe to fulfil the order. |
failed |
Payment was attempted but declined or errored. |
pending |
Payment initiated but not yet complete. Check again or wait for a webhook. |
abandoned |
Customer opened the checkout page but did not pay. |
reversed |
A successful payment was reversed (rare). |
refunded |
The payment has been refunded. |
The problem: If you accept payments for different amounts (different products, different plans), an attacker could take the reference from a ₦100 payment and present it as a ₦10,000 payment. If you only check status === 'success' without checking the amount, you would fulfil the expensive order for free.
The fix: Pass expectedAmount when verifying. The library will throw an error if there is a mismatch.
try {
const result = await client.verifyTransaction(reference, {
expectedAmount: 50000, // ₦500.00 in kobo
expectedCurrency: 'NGN',
});
// If we get here, the amount and currency matched
await fulfillOrder(reference);
} catch (err) {
if (err instanceof PaymentAfricaAmountMismatchError) {
// The provider says the customer paid a different amount than expected.
// Do NOT fulfil the order.
console.error(`Amount mismatch on ${reference}: expected ${err.expected}, got ${err.actual}`);
} else if (err instanceof PaymentAfricaCurrencyMismatchError) {
// or pays with a different currency
console.error(`Currency mismatch on ${reference}: expected ${err.expected}, got ${err.actual}`);
} else {
throw err; // something else went wrong
}
}
AmountMismatchErrorandCurrencyMismatchErrorare only thrown when the payment status issuccess. If the payment failed or is pending, a mismatch is reported in the result flags (result.amountMatches,result.currencyMatches) but does not throw — because there is nothing to fulfil anyway.
Webhooks (covered in the next section) are delivered at least once. That means the same event can arrive two, three, or more times. Customers also sometimes refresh their callback page. Your server might restart mid-processing.
Without protection, these things can cause you to ship an order twice, email the customer twice, or credit a wallet twice.
The library solves this with an idempotency store — a cache that remembers which references you have already processed.
import { PaymentAfrica, MemoryIdempotencyStore } from 'payment-africa';
const store = new MemoryIdempotencyStore();
const client = new PaymentAfrica({
paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY },
idempotencyStore: store,
});
// First call — hits the Paystack API
const r1 = await client.verifyTransaction('order_123');
console.log(r1.fromCache); // false
// Second call with the same reference — returns cached result immediately
const r2 = await client.verifyTransaction('order_123');
console.log(r2.fromCache); // true — no API call madeThe in-memory store is simple and works well for applications running on a single server. It resets when your server restarts.
If you run your application on more than one server (which you probably do in production), the in-memory store does not work — each server has its own separate memory. You need a shared store. Redis is the standard solution.
import Redis from 'ioredis';
import { PaymentAfrica, RedisIdempotencyStore } from 'payment-africa';
// Your existing Redis client
const redis = new Redis(process.env.REDIS_URL);
const client = new PaymentAfrica({
paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY },
idempotencyStore: new RedisIdempotencyStore({ client: redis }),
});If you use node-redis (the package called redis on npm, not ioredis), use the provided adapter:
import { createClient } from 'redis';
import { RedisIdempotencyStore, adaptNodeRedisV4 } from 'payment-africa';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
const store = new RedisIdempotencyStore({
client: adaptNodeRedisV4(redisClient),
});The library only caches terminal results:
- Cached:
success,failed— these will not change - Not cached:
pending,abandoned— the status may change, so we always check fresh
Cached results expire after 24 hours by default. You can change this:
const result = await client.verifyTransaction('order_123', {
idempotencyTtlMs: 7 * 24 * 60 * 60 * 1000, // cache for 7 days
});// Full refund
const refund = await paystack.refund({
reference: 'order_123',
});
// Partial refund — ₦100.00 out of a ₦500.00 payment
const partialRefund = await paystack.refund({
reference: 'order_123',
amount: 10000, // ₦100.00 in kobo
reason: 'Customer requested partial refund for item not received',
});What the refund result looks like:
console.log(refund.refundId); // The provider's ID for this refund
console.log(refund.status); // 'pending' | 'processed' | 'failed'
console.log(refund.amount); // Amount refunded in minor units
console.log(refund.currency); // Currency codeFlutterwave refunds: Flutterwave requires their internal transaction ID to issue a refund, not your reference. The library handles this automatically — it looks up the transaction ID for you behind the scenes. You still just pass your
reference.
When a payment is completed, Paystack or Flutterwave sends a POST request to a URL on your server. This is called a webhook. It is more reliable than the callback redirect because it comes directly from the provider, not from the customer's browser.
You should use webhooks for anything important: sending receipts, fulfilling orders, updating your database.
The library does three things automatically:
- Signature verification — it checks that the request genuinely came from Paystack/Flutterwave, not from an attacker pretending to be them. If the check fails, the request is rejected silently.
- Replay protection — if the same event arrives twice (which happens often), your handler is only called once.
- Error recovery — if your handler throws an error, the library un-marks the event so the next delivery tries again.
import { createWebhookHandler } from 'payment-africa';
const handler = createWebhookHandler({
// Configure the providers you want to accept webhooks from
paystack: {
secretKey: process.env.PAYSTACK_SECRET_KEY,
},
flutterwave: {
secretHash: process.env.FLW_WEBHOOK_SECRET_HASH,
// This is NOT your API secret key.
// It is a separate value you set in your Flutterwave dashboard under:
// Settings → API Keys → Webhook secret hash
},
// An idempotency store prevents duplicate processing
store: new MemoryIdempotencyStore(),
// Your business logic
onEvent: async (event) => {
console.log('Event type:', event.type); // e.g. 'charge.success'
console.log('Provider:', event.provider); // 'paystack' or 'flutterwave'
if (event.type === 'charge.success' && event.transaction) {
const tx = event.transaction;
console.log('Reference:', tx.reference);
console.log('Amount paid:', tx.amount, tx.currency);
console.log('Customer:', tx.customer.email);
// Fulfil the order
await fulfillOrder(tx.reference, tx.amount);
}
if (event.type === 'charge.failed' && event.transaction) {
await markOrderFailed(event.transaction.reference);
}
},
});The library does not care which web framework you use. You give it the raw request body and headers, and it does the rest.
Important: You must give it the raw bytes of the request body, not a parsed JSON object. This is because the signature is computed over the exact bytes — even tiny differences (extra spaces, different key ordering) would break verification.
import express from 'express';
const app = express();
app.post(
'/webhooks/paystack',
express.raw({ type: '*/*' }), // ← this line is critical — raw body, not json()
async (req, res) => {
const result = await handler({
provider: 'paystack',
rawBody: req.body, // Buffer
headers: req.headers,
});
if (result.status === 'invalid_signature') {
return res.status(401).json({ error: 'Invalid signature' });
}
res.status(200).end(); // Always respond 200 quickly — providers retry on 4xx/5xx
},
);
app.post('/webhooks/flutterwave', express.raw({ type: '*/*' }), async (req, res) => {
const result = await handler({
provider: 'flutterwave',
rawBody: req.body,
headers: req.headers,
});
if (result.status === 'invalid_signature') {
return res.status(401).json({ error: 'Invalid signature' });
}
res.status(200).end();
});// app/api/webhooks/paystack/route.ts
import { createWebhookHandler, MemoryIdempotencyStore } from 'payment-africa';
const handler = createWebhookHandler({
paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY! },
store: new MemoryIdempotencyStore(),
onEvent: async (event) => {
// your logic here
},
});
export async function POST(request: Request) {
const rawBody = await request.text(); // raw string body
const headers = Object.fromEntries(request.headers);
const result = await handler({
provider: 'paystack',
rawBody,
headers,
});
return new Response('', {
status: result.status === 'invalid_signature' ? 401 : 200,
});
}import Fastify from 'fastify';
const app = Fastify();
// Tell Fastify not to parse the body — we need raw bytes
app.addContentTypeParser('*', { parseAs: 'buffer' }, (_, body, done) => {
done(null, body);
});
app.post('/webhooks/paystack', async (req, reply) => {
const result = await handler({
provider: 'paystack',
rawBody: req.body as Buffer,
headers: req.headers,
});
reply.code(result.status === 'invalid_signature' ? 401 : 200).send();
});| Event type | When it fires |
|---|---|
charge.success |
A payment completed successfully |
charge.failed |
A payment attempt failed |
transfer.success |
A payout you initiated completed |
transfer.failed |
A payout you initiated failed |
refund.processed |
A refund completed |
refund.failed |
A refund failed |
unknown |
A new event type from the provider that the library doesn't recognise yet |
Always handle
unknowngracefully — providers occasionally add new event types. Your handler should not crash if it sees one.
onEvent: async (event) => {
event.provider; // 'paystack' or 'flutterwave'
event.type; // normalized event type (see table above)
event.rawEventType; // the provider's original event name, e.g. 'charge.success'
event.eventId; // a stable unique ID for this specific event
event.transaction; // NormalizedTransaction if the event is about a transaction, else undefined
event.raw; // the full original payload from the provider (for debugging)
};Paystack signs webhooks with HMAC-SHA512 (cryptographically strong). Flutterwave uses a simpler approach — a shared secret string is included in every webhook header. Both approaches are secure as long as your secret is kept private.
Because Flutterwave's approach is simpler, we recommend always re-verifying the transaction after receiving a Flutterwave webhook:
onEvent: async (event) => {
if (event.provider === 'flutterwave' && event.transaction) {
// Don't just trust the webhook payload — verify with the API too
const result = await client.verifyTransaction(event.transaction.reference, {
expectedAmount: event.transaction.amount,
});
if (result.transaction.status === 'success') {
await fulfillOrder(result.transaction.reference);
}
}
};Every error thrown by payment-africa is a subclass of PaymentAfricaError. You can catch errors precisely by type.
import {
PaymentAfricaError,
PaymentAfricaNetworkError,
PaymentAfricaAuthenticationError,
PaymentAfricaValidationError,
PaymentAfricaRateLimitError,
PaymentAfricaProviderError,
PaymentAfricaWebhookSignatureError,
} from 'payment-africa';
try {
await paystack.verifyTransaction('order_123');
} catch (err) {
if (err instanceof PaymentAfricaAuthenticationError) {
// Your API key is wrong or expired. Fix it — retrying won't help.
console.error('Check your secret key');
} else if (err instanceof PaymentAfricaValidationError) {
// You passed bad input (empty reference, negative amount, etc.)
// Fix your code — retrying won't help.
console.error('Bad input:', err.message);
} else if (err instanceof PaymentAfricaNetworkError) {
// A network issue. Could retry.
console.error('Network problem, try again');
} else if (err instanceof PaymentAfricaRateLimitError) {
// You are making too many requests. Slow down and retry.
console.error('Rate limited');
} else if (err instanceof PaymentAfricaProviderError) {
// The provider returned an error response (4xx or 5xx)
console.error('Provider error:', err.message, 'status:', err.statusCode);
} else if (err instanceof PaymentAfricaError) {
// Some other library error
console.error('Payment error:', err.code, err.message);
}
}Every error has a retryable boolean that tells you whether it is safe to try the operation again:
try {
await paystack.verifyTransaction('order_123');
} catch (err) {
if (err instanceof PaymentAfricaError && err.retryable) {
// Safe to try again (network blip, server error, rate limit)
await retryLater('verifyTransaction', 'order_123');
} else {
// Do not retry (wrong key, bad input, etc.)
await logPermanentFailure(err);
}
}| Error class | When it happens | Retryable? |
|---|---|---|
PaymentAfricaConfigError |
Missing or invalid configuration at startup | No |
PaymentAfricaAuthenticationError |
Your secret key is wrong or expired | No |
PaymentAfricaValidationError |
Bad input (empty reference, float amount, missing email) | No |
PaymentAfricaNetworkError |
Network connection failed | Yes |
PaymentAfricaTimeoutError |
Request took too long (default 30 seconds) | Yes |
PaymentAfricaRateLimitError |
Too many requests to the provider | Yes |
PaymentAfricaProviderError |
The provider returned a 4xx or 5xx error | Depends on status |
PaymentAfricaWebhookSignatureError |
Webhook signature verification failed | No |
catch (err) {
if (err instanceof PaymentAfricaError) {
console.log(err.code); // machine-readable code like 'NETWORK_ERROR'
console.log(err.message); // human-readable description
console.log(err.provider); // 'paystack', 'flutterwave', or 'internal'
console.log(err.statusCode); // HTTP status code if applicable
console.log(err.retryable); // true or false
// Safe to log — raw provider response is hidden by default
console.log(err.toJSON());
// Pass true if you need to see the raw response (for debugging only)
console.log(err.toJSON(true));
}
}Why is the raw response hidden? Provider responses can contain partial card numbers, customer emails, and internal identifiers. If you log
err.rawto a third-party log service, you could accidentally expose sensitive data. The default is safe; opt in withtoJSON(true)only when debugging.
The library is silent by default — it does not print anything to your console. This is intentional. Libraries should not decide where their output goes.
import { Paystack, createConsoleLogger } from 'payment-africa';
const paystack = new Paystack({
secretKey: process.env.PAYSTACK_SECRET_KEY,
logger: createConsoleLogger('debug'), // 'debug' | 'info' | 'warn' | 'error'
});If you use pino, winston, bunyan, or any other logging library, you can plug it straight in. The library works with any object that has debug, info, warn, and error methods:
import pino from 'pino';
import { PaymentAfrica } from 'payment-africa';
const logger = pino();
const client = new PaymentAfrica({
paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY },
logger: logger, // pino works directly
});import winston from 'winston';
const logger = winston.createLogger({
/* your config */
});
const client = new PaymentAfrica({
paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY },
logger: logger, // winston works too
});new Paystack({
secretKey: string, // Required. Your Paystack secret key (sk_test_... or sk_live_...)
publicKey?: string, // Optional. Your public key (only needed for client-side use)
baseUrl?: string, // Optional. Default: 'https://api.paystack.co'
timeoutMs?: number, // Optional. Request timeout. Default: 30000 (30 seconds)
http?: {
maxRetries?: number, // Optional. How many times to retry on failure. Default: 3
baseDelayMs?: number, // Optional. Base delay between retries. Default: 500ms
},
logger?: Logger, // Optional. Your logger. Default: silent
})new Flutterwave({
secretKey: string, // Required. Your Flutterwave secret key
publicKey?: string, // Optional
encryptionKey?: string, // Optional. Needed only for direct card charges
baseUrl?: string, // Optional. Default: 'https://api.flutterwave.com/v3'
timeoutMs?: number, // Optional. Default: 30000
http?: {
maxRetries?: number, // Optional. Default: 3
baseDelayMs?: number, // Optional. Default: 500ms
},
logger?: Logger,
})new PaymentAfrica({
paystack?: PaystackOptions, // Optional (but at least one provider is required)
flutterwave?: FlutterwaveOptions,
idempotencyStore?: IdempotencyStore, // Optional. MemoryIdempotencyStore or RedisIdempotencyStore
referencePrefixes?: { // Optional. For multi-provider routing
paystack?: string, // e.g. 'ps_'
flutterwave?: string, // e.g. 'flw_'
},
logger?: Logger,
})createWebhookHandler({
paystack?: {
secretKey: string, // Your Paystack secret key
},
flutterwave?: {
secretHash: string, // Your Flutterwave webhook secret hash (from the dashboard)
},
onEvent: (event) => void | Promise<void>, // Your handler — required
store?: IdempotencyStore, // Optional. Enables replay protection
dedupeTtlMs?: number, // Optional. How long to remember processed events. Default: 7 days
releaseOnError?: boolean, // Optional. Roll back dedupe record on handler error. Default: true
logger?: Logger,
})If you use TypeScript, all types are available as named imports:
import type {
// Provider result types
NormalizedTransaction,
InitializeTransactionResult,
NormalizedRefund,
// Input types
InitializeTransactionParams,
RefundParams,
// Webhook types
NormalizedWebhookEvent,
WebhookEventType,
WebhookInput,
WebhookOutcome,
// Enums and unions
Channel,
Currency,
MinorUnits,
TransactionStatus,
ProviderName,
// Store types
IdempotencyStore,
IdempotencyRecord,
// Redis adapter
RedisLike,
// Logger
Logger,
LogLevel,
// The provider interface (for writing your own)
PaymentProvider,
} from 'payment-africa';If you want to add a third provider, implement the PaymentProvider interface:
import type { PaymentProvider, NormalizedTransaction /* ... */ } from 'payment-africa';
class MyProvider implements PaymentProvider {
name = 'myprovider' as const;
async initializeTransaction(params) {
/* ... */
}
async verifyTransaction(reference) {
/* ... */
}
async refund(params) {
/* ... */
}
}Q: Do I need to configure both providers?
No. You only need to configure the providers you actually want to use. If you only have Paystack, just configure Paystack. The library will use whichever providers are configured.
Q: What happens if the provider's API is down?
The library will throw a PaymentAfricaNetworkError or PaymentAfricaTimeoutError, both of which have retryable: true. The HTTP client automatically retries up to 3 times with exponential backoff before giving up. You can increase or decrease this with the maxRetries option.
Q: Can I use this in a serverless function (AWS Lambda, Vercel, etc.)?
Yes. Use MemoryIdempotencyStore if your functions don't share state, or RedisIdempotencyStore with an external Redis instance if you need shared state across function instances. Note: the in-memory store resets on every cold start.
Q: Is the library safe to use in multiple concurrent requests?
Yes. The library has no global state. Each Paystack, Flutterwave, or PaymentAfrica instance is independent.
Q: What currencies are supported?
The library supports any currency code your provider supports. Paystack supports NGN, GHS, ZAR, USD, and a few others. Flutterwave supports a wider range. Check each provider's documentation for their full currency list.
Q: I'm getting "amount must be a positive integer" — what's wrong?
You are probably passing a decimal amount. Remember that all amounts are in minor units (kobo for NGN). 50000 is ₦500.00. 500 is ₦5.00. 49.99 is not valid — convert it to 4999 first.
Q: Can I use payment-africa with CommonJS (require) instead of ESM (import)?
Yes. Both module systems are supported:
// ESM
import { Paystack } from 'payment-africa';
// CommonJS
const { Paystack } = require('payment-africa');Q: How do I run tests without hitting the real APIs?
The library is designed for this. Each provider class accepts an httpClient option. In your tests, pass a mock:
import { Paystack } from 'payment-africa';
const mockHttp = {
request: async () => ({
status: 200,
data: {
status: true,
message: 'OK',
data: {
/* fake response */
},
},
}),
};
const paystack = new Paystack({
secretKey: 'sk_test_fake',
httpClient: mockHttp,
});Q: The webhook arrives but onEvent is never called — what's wrong?
The two most common causes: (1) your framework is parsing the body as JSON before the handler sees it — make sure you are passing raw bytes, not a parsed object (see the Express and Next.js examples in section 13); (2) signature verification is failing silently — the result object from handler(...) will have status: 'invalid_signature', log it to confirm.
Q: I need a feature that isn't here. What do I do?
Open an issue on GitHub or send a pull request. If you need to call an API endpoint not covered by the library right now, you can use the provider instance's raw field from any NormalizedTransaction to see what the provider returned, and make the call directly using your preferred HTTP client in the meantime.