Bora PesaBora Pesa

Plugins

Retry, idempotency, logging, and writing custom plugins.

Added in v0.1.0

Plugins intercept requests and webhook events at specific points in the lifecycle. They are plain objects passed in the plugins array of PesaConfig. Order matters — plugins are composed in sequence.

import { createPesa } from '@borapesa/pesa';
import { retryPlugin, idempotencyPlugin, loggingPlugin } from '@borapesa/pesa/plugins';

const pesa = createPesa({
  provider: new ClickPesaProvider({ ... }),
  plugins: [
    idempotencyPlugin(),   // must be before retryPlugin
    retryPlugin({ maxAttempts: 3 }),
    loggingPlugin({ level: 'info' }),
  ],
});

Built-in plugins

retryPlugin()

Retries payments on in-progress statuses (AMBIGUOUS, PROCESSING, QUEUED) with configurable backoff.

retryPlugin({
  maxAttempts: 3,                    // default: 3
  backoff: 'exponential',           // 'exponential' | 'linear' | 'fixed'
  baseDelayMs: 1000,                // default: 1000
})
BackoffFormulaDelay sequence (base=1000)
exponentialbase * 2^attempt1s, 2s, 4s
linearbase * (attempt + 1)1s, 2s, 3s
fixedbase1s, 1s, 1s

idempotencyPlugin()

Prevents duplicate charges on network retries. Keys on operation + reference — the same reference sent to the same operation twice throws an error.

idempotencyPlugin(); // in-memory set, persistent store planned for v1.0

Place this before retryPlugin in the array. The retry loop in createPesa won't re-run beforeRequest hooks on retries, so idempotency checks only fire once per logical request.

loggingPlugin()

Structured logs for all SDK operations. Redacts phone numbers and email addresses from payloads.

loggingPlugin({
  level: 'info',           // 'debug' | 'info' | 'warn' | 'error'
  logger: console,         // swap for Pino, Winston, etc.
})

Logs include: operation name, status, duration in ms, and sanitized payload.

Devtools

The @borapesa/devtools package provides developer utilities.

tunnelPlugin()

Starts a Cloudflare Quick Tunnel for local webhook development. Requires cloudflared on the system PATH (free, no account needed).

pnpm add -D @borapesa/devtools
import { tunnelPlugin } from '@borapesa/devtools';

const pesa = createPesa({
  provider: new ClickPesaProvider({ ... }),
  plugins: [
    ...(process.env.NODE_ENV !== 'production' ? [tunnelPlugin({ port: 8080 })] : []),
  ],
});
tunnelPlugin({
  port:        3000,               // local port to expose (default: 3000)
  log:         true,               // print the tunnel URL to console (default: true)
  webhookPath: '/pesa/webhook',    // path shown in the banner (default: '/pesa/webhook')
  binary:      'cloudflared',      // override for testing with mock binaries
})

On startup, it prints the public webhook URL:

🛜  @borapesa/devtools
   Tunnel ready:  https://cool-fox.trycloudflare.com
   Webhook URL:   https://cool-fox.trycloudflare.com/pesa/webhook

This is for local development only. Guard it behind NODE_ENV !== 'production'. Do not use it in production — webhooks should go directly to your deployed server.

Plugin interface

Write custom plugins by implementing the PesaPlugin interface:

interface PesaPlugin {
  name: string;

  // Intercept outgoing request
  beforeRequest?: (ctx: RequestContext) => Promise<RequestContext>;

  // Intercept provider response — set ctx.retry = true to trigger retry
  afterResponse?: (ctx: ResponseContext) => Promise<ResponseContext>;

  // React to verified, about-to-be-persisted events
  onPaymentEvent?: (event: PaymentEvent) => Promise<void>;

  // Called once at startup with the assembled PesaInstance
  init?: (pesa: PesaInstance) => void;
}

Example: SMS notification plugin

const smsPlugin: PesaPlugin = {
  name: 'sms-notify',

  async onPaymentEvent(event) {
    if (event.type !== 'PAYMENT_SUCCESS') return;

    await fetch('https://sms-gateway.example.com/send', {
      method: 'POST',
      body: JSON.stringify({
        phone: event.metadata?.customerPhone,
        message: `Payment of TZS ${event.amount} confirmed. Ref: ${event.reference}`,
      }),
    });
  },
};

const pesa = createPesa({
  provider: new ClickPesaProvider({ ... }),
  plugins: [smsPlugin, retryPlugin()],
});

On this page