Skip to content

GraceYourCode/payment-africa

Repository files navigation

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: 'customer@example.com',
  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: '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

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: 'customer@example.com',
  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: '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.

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: 'customer@example.com',
});

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.

About

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.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors