Plugins
Retry, idempotency, logging, and writing custom plugins.
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
})| Backoff | Formula | Delay sequence (base=1000) |
|---|---|---|
exponential | base * 2^attempt | 1s, 2s, 4s |
linear | base * (attempt + 1) | 1s, 2s, 3s |
fixed | base | 1s, 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.0Place 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/devtoolsimport { 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/webhookThis 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()],
});