npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

payment-africa

v0.1.0

Published

A unified payment utility library for African payment providers (Paystack, Flutterwave) with webhook verification, retry queues, and currency conversion.

Downloads

31

Readme

payment-africa — Developer Documentation

A plain-language guide to everything the library does and how to use it.


Table of Contents

  1. What this library is
  2. Before you start
  3. Installation
  4. A note on amounts — read this first
  5. Basic setup
  6. Collecting a payment (Paystack)
  7. Collecting a payment (Flutterwave)
  8. Using both providers at once
  9. Verifying a payment
  10. Checking that the right amount was paid
  11. Preventing duplicate fulfilment
  12. Issuing refunds
  13. Webhooks — getting notified when a payment happens
  14. Understanding errors
  15. Logging
  16. Configuration reference
  17. TypeScript types reference
  18. Frequently asked questions

1. What this library is

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 |


2. Before you start

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 and FLWSECK_TEST-... on Flutterwave. Only switch to live keys when you are ready to charge real customers.


3. Installation

npm install payment-africa

That 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.


4. A note on amounts — read this first

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.00

This is the same convention used by Stripe, Paystack's own API, and most modern payment libraries.


5. Basic setup

Using only Paystack

import { Paystack } from 'payment-africa';

const paystack = new Paystack({
  secretKey: process.env.PAYSTACK_SECRET_KEY, // never hardcode this
});

Using only Flutterwave

import { Flutterwave } from 'payment-africa';

const flutterwave = new Flutterwave({
  secretKey: process.env.FLW_SECRET_KEY,
});

Using both (recommended)

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 .env file (and add .env to your .gitignore). In production, use your hosting platform's secret management (Heroku config vars, Railway variables, AWS Secrets Manager, etc.).


6. Collecting a payment (Paystack)

There are two steps to every payment:

  1. Initialize — you tell Paystack about the payment and get back a URL to send the customer to.
  2. 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.

Step 1 — Initialize a payment

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: '[email protected]',
  callbackUrl: 'https://yoursite.com/payment/callback',
});

console.log(result.authorizationUrl);
// → "https://checkout.paystack.com/..." — send the customer here for checkout

What 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. |

Limiting payment channels

const result = await paystack.initializeTransaction({
  reference: 'order_123',
  amount: 50000,
  currency: 'NGN',
  email: '[email protected]',
  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

Step 2 — Redirect the customer

After initializeTransaction succeeds, redirect your customer to result.authorizationUrl. Paystack's checkout page handles the rest.

Step 3 — Handle the callback

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).


7. Collecting a payment (Flutterwave)

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: '[email protected]',
  callbackUrl: 'https://yoursite.com/payment/callback',
});

console.log(result.authorizationUrl);
// → "https://checkout.flutterwave.com/..." — send the customer here for payment

Flutterwave's API actually uses major units (naira, not kobo). The library converts automatically. You always pass minor units to payment-africa regardless of the provider.

After the customer pays, Flutterwave redirects them to your callbackUrl with a tx_ref query parameter that matches your reference.


8. Using both providers at once

If you have both providers configured, use the PaymentAfrica facade so you don't have to track which provider each payment used.

The reference prefix system

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: '[email protected]',
  },
  { 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: '[email protected]',
  },
  { 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.

What if you only have one provider?

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: '[email protected]',
});

9. Verifying a payment

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
}

What verifyTransaction returns

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) |

Payment statuses

| 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. |


10. Checking that the right amount was paid

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
  }
}

AmountMismatchError and CurrencyMismatchError are only thrown when the payment status is success. 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.


11. Preventing duplicate fulfilment

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.

In-memory store (single server)

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 made

The in-memory store is simple and works well for applications running on a single server. It resets when your server restarts.

Redis store (multiple servers — production)

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),
});

How caching works

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
});

12. Issuing refunds

// 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 code

Flutterwave 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.


13. Webhooks — getting notified when a payment happens

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 three things webhooks protect against

The library does three things automatically:

  1. 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.
  2. Replay protection — if the same event arrives twice (which happens often), your handler is only called once.
  3. Error recovery — if your handler throws an error, the library un-marks the event so the next delivery tries again.

Setting up the handler

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);
    }
  },
});

Wiring the handler into your server

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.

Express

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();
});

Next.js (App Router)

// 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,
  });
}

Fastify

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 types

| 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 unknown gracefully — providers occasionally add new event types. Your handler should not crash if it sees one.

What the event object looks like

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)
};

A note on Flutterwave's webhook security

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);
    }
  }
};

14. Understanding errors

Every error thrown by payment-africa is a subclass of PaymentAfricaError. You can catch errors precisely by type.

Catching specific errors

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);
  }
}

The retryable flag

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 types and when they occur

| 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 |

Getting more detail from errors

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.raw to a third-party log service, you could accidentally expose sensitive data. The default is safe; opt in with toJSON(true) only when debugging.


15. Logging

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.

Enabling console logging (development)

import { Paystack, createConsoleLogger } from 'payment-africa';

const paystack = new Paystack({
  secretKey: process.env.PAYSTACK_SECRET_KEY,
  logger: createConsoleLogger('debug'), // 'debug' | 'info' | 'warn' | 'error'
});

Using your own logger (production)

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
});

16. Configuration reference

Paystack options

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
})

Flutterwave options

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,
})

PaymentAfrica options

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 options

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,
})

17. TypeScript types reference

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';

Writing a custom provider

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) {
    /* ... */
  }
}

18. Frequently asked questions

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.