Event System

Webhooks, event persistence, and typed event handlers in Bora Pesa.

Added in v0.1.0

Every verified webhook is normalized into a PaymentEvent, persisted to the event store, and emitted to registered handlers. This is the source of truth for all payment activity in your application.

The lifecycle

Provider sends webhook → POST /api/pesa/webhook

  1. Provider adapter verifies cryptographic signature (HMAC, checksum, etc.)
  2. SDK assigns a UUID to the event
  3. Plugin onPaymentEvent hooks run (webhook verification, logging)
  4. Event is persisted to the event store
  5. pesa.on() handlers fire — in registration order

Events are persisted before handlers fire. If your handler crashes, the event is already in the database — you can safely retry without double-processing.

PaymentEvent

interface PaymentEvent {
  id:         string;           // UUID — generated by SDK
  type:       PaymentEventType; // PAYMENT_SUCCESS | PAYMENT_FAILED | ...
  orderId:    string;           // Provider transaction ID
  reference:  string;           // Your order ID, echoed back
  amount:     number;           // TZS — whole integer
  currency:   'TZS';
  status:     PaymentStatus;    // SUCCESS | FAILED | PENDING | ...
  provider:   ProviderName;     // clickpesa | selcom | ...
  timestamp:  Date;
  metadata?:  Record<string, unknown>;
  raw?:       unknown;          // Original webhook payload
}

Event types

type PaymentEventType =
  | 'PAYMENT_SUCCESS'       // Payment completed
  | 'PAYMENT_FAILED'        // Payment failed
  | 'PAYMENT_PENDING'       // Payment initiated, awaiting result
  | 'DISBURSEMENT_SUCCESS'  // Payout sent
  | 'DISBURSEMENT_FAILED';  // Payout failed

Registering handlers

Use pesa.on() to react to events. Handlers can be async — errors in one handler don't prevent others from firing.

// Single handler
pesa.on('PAYMENT_SUCCESS', async (event) => {
  await db.orders.update({
    where: { id: event.reference },
    data:  { status: 'paid' },
  });
});

// Multiple handlers for the same event type
pesa.on('PAYMENT_SUCCESS', (event) => {
  console.log(`Payment ${event.reference}: TZS ${event.amount}`);
});

pesa.on('PAYMENT_SUCCESS', async (event) => {
  await emailService.sendReceipt(event.reference);
});

// React to payment failures
pesa.on('PAYMENT_FAILED', async (event) => {
  await notifications.notifySupport(event);
});

// Monitor disbursements
pesa.on('DISBURSEMENT_SUCCESS', (event) => {
  console.log(`Payout ${event.reference} sent via ${event.provider}`);
});

Plugin onPaymentEvent hooks

Plugins also have onPaymentEvent hooks that fire before user-registered handlers. This is where built-in behavior like webhook verification enforcement runs:

// In webhookVerifyPlugin:
async onPaymentEvent(event) {
  if (!process.env.BORAPESA_WEBHOOK_SECRET) {
    throw new PesaWebhookError('BORAPESA_WEBHOOK_SECRET is not set');
  }
}

Plugins fire in registration order. User handlers fire after all plugin hooks complete.

Querying past events

The event store supports querying by ID, reference, or order ID:

// Via PesaDatabaseAdapter (if you have access to the adapter)
const events = await db.getEventsByReference('order_abc123');
const event  = await db.getEvent('uuid-here');

For most use cases, pesa.on() handlers combined with your own database are simpler — store what you need in your schema when the handler fires.

On this page