Event System
Webhooks, event persistence, and typed event handlers in Bora Pesa.
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 orderEvents 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 failedRegistering 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.