pesafy 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1519 @@
1
+ # pesafy 💳
2
+
3
+ > **Type-safe M-PESA Daraja SDK** for Node.js, Bun, Deno, Cloudflare Workers,
4
+ > Next.js, Fastify, Hono, and Express.
5
+
6
+ [![npm version](https://img.shields.io/npm/v/pesafy.svg)](https://www.npmjs.com/package/pesafy)
7
+ [![npm downloads](https://img.shields.io/npm/dm/pesafy.svg)](https://www.npmjs.com/package/pesafy)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue.svg)](https://www.typescriptlang.org/)
10
+ [![CI](https://github.com/levos-snr/pesafy/actions/workflows/ci.yml/badge.svg)](https://github.com/levos-snr/pesafy/actions/workflows/ci.yml)
11
+ [![codecov](https://codecov.io/github/levos-snr/pesafy/graph/badge.svg?token=JYK2BS1ZZF)](https://codecov.io/github/levos-snr/pesafy)
12
+
13
+ ---
14
+
15
+ ## Table of Contents
16
+
17
+ - [Installation](#installation)
18
+ - [Quick Start](#quick-start)
19
+ - [CLI](#cli)
20
+ - [API Reference](#api-reference)
21
+ - [STK Push (M-PESA Express)](#stk-push-m-pesa-express)
22
+ - [C2B (Customer to Business)](#c2b-customer-to-business)
23
+ - [B2C Account Top Up](#b2c-account-top-up)
24
+ - [B2C Disbursement](#b2c-disbursement)
25
+ - [B2B Express Checkout](#b2b-express-checkout)
26
+ - [B2B Pay Bill](#b2b-pay-bill)
27
+ - [B2B Buy Goods](#b2b-buy-goods)
28
+ - [Account Balance](#account-balance)
29
+ - [Transaction Status](#transaction-status)
30
+ - [Transaction Reversal](#transaction-reversal)
31
+ - [Tax Remittance (KRA)](#tax-remittance-kra)
32
+ - [Dynamic QR Code](#dynamic-qr-code)
33
+ - [Bill Manager](#bill-manager)
34
+ - [Webhooks](#webhooks)
35
+ - [Framework Adapters](#framework-adapters)
36
+ - [Express](#express-adapter)
37
+ - [Hono (Bun / Cloudflare Workers)](#hono-adapter)
38
+ - [Next.js App Router](#nextjs-adapter)
39
+ - [Fastify](#fastify-adapter)
40
+ - [Branded Types](#branded-types)
41
+ - [Error Handling](#error-handling)
42
+ - [Utilities](#utilities)
43
+ - [Configuration Reference](#configuration-reference)
44
+ - [Roadmap](#roadmap)
45
+
46
+ ---
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ npm install pesafy # npm
52
+ yarn add pesafy # yarn
53
+ pnpm add pesafy # pnpm
54
+ bun add pesafy # bun
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Quick Start
60
+
61
+ ```typescript
62
+ import { Mpesa } from 'pesafy'
63
+
64
+ const mpesa = new Mpesa({
65
+ consumerKey: process.env.MPESA_CONSUMER_KEY!,
66
+ consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
67
+ environment: 'sandbox', // "sandbox" | "production"
68
+ lipaNaMpesaShortCode: '174379',
69
+ lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
70
+ })
71
+
72
+ // Send an STK Push
73
+ const response = await mpesa.stkPush({
74
+ amount: 100,
75
+ phoneNumber: '0712345678',
76
+ callbackUrl: 'https://yourdomain.com/api/mpesa/callback',
77
+ accountReference: 'INV-001',
78
+ transactionDesc: 'Payment',
79
+ })
80
+
81
+ console.log(response.CheckoutRequestID)
82
+ ```
83
+
84
+ ---
85
+
86
+ ## CLI
87
+
88
+ The pesafy CLI lets you interact with Daraja directly from your terminal — great
89
+ for testing, debugging, and scripting.
90
+
91
+ ### Setup
92
+
93
+ ```bash
94
+ # Interactive setup — creates .env in your project
95
+ npx pesafy init
96
+
97
+ # Validate your .env config
98
+ npx pesafy doctor
99
+ ```
100
+
101
+ ### Commands
102
+
103
+ ```
104
+ npx pesafy init Scaffold .env interactively
105
+ npx pesafy doctor Validate .env for common mistakes
106
+ npx pesafy token Print a fresh OAuth token
107
+ npx pesafy encrypt Encrypt initiator password → SecurityCredential
108
+ npx pesafy validate-phone <phone> Validate / normalise a Kenyan phone number
109
+ npx pesafy stk-push Initiate an STK Push (interactive prompts)
110
+ npx pesafy stk-push --amount 100 --phone 0712345678 --ref INV-001
111
+ npx pesafy stk-query <checkoutId> Check STK Push status
112
+ npx pesafy balance Query M-PESA account balance (async)
113
+ npx pesafy balance --shortcode 600000 --identifier-type 4
114
+ npx pesafy reversal <txId> Initiate a transaction reversal
115
+ npx pesafy register-c2b-urls Register C2B Confirmation + Validation URLs
116
+ npx pesafy simulate-c2b Simulate a C2B payment (sandbox only)
117
+ npx pesafy version Print library version
118
+ npx pesafy help Show help
119
+ ```
120
+
121
+ ### Environment variables read by the CLI
122
+
123
+ ```bash
124
+ MPESA_CONSUMER_KEY
125
+ MPESA_CONSUMER_SECRET
126
+ MPESA_ENVIRONMENT # sandbox | production
127
+ MPESA_SHORTCODE
128
+ MPESA_PASSKEY
129
+ MPESA_CALLBACK_URL
130
+ MPESA_INITIATOR_NAME
131
+ MPESA_INITIATOR_PASSWORD
132
+ MPESA_CERTIFICATE_PATH # path to .cer file
133
+ MPESA_RESULT_URL
134
+ MPESA_QUEUE_TIMEOUT_URL
135
+ ```
136
+
137
+ ---
138
+
139
+ ## API Reference
140
+
141
+ ### Instantiating the client
142
+
143
+ ```typescript
144
+ import { Mpesa } from 'pesafy'
145
+
146
+ const mpesa = new Mpesa({
147
+ consumerKey: '...',
148
+ consumerSecret: '...',
149
+ environment: 'sandbox',
150
+
151
+ // STK Push
152
+ lipaNaMpesaShortCode: '174379',
153
+ lipaNaMpesaPassKey: 'bfb279...',
154
+
155
+ // Initiator-based APIs (B2C, B2B Pay Bill/Buy Goods, Reversal, Balance, Tax)
156
+ initiatorName: 'testapi',
157
+ initiatorPassword: 'Safaricom123!',
158
+ certificatePath: './SandboxCertificate.cer',
159
+
160
+ // HTTP tuning (optional)
161
+ retries: 4, // default: 4
162
+ retryDelay: 2000, // default: 2 000 ms
163
+ timeout: 30000, // default: 30 000 ms (per attempt)
164
+ })
165
+ ```
166
+
167
+ ---
168
+
169
+ ### STK Push (M-PESA Express)
170
+
171
+ Prompts the customer to enter their M-PESA PIN on their phone.
172
+
173
+ ```typescript
174
+ // Initiate
175
+ const push = await mpesa.stkPush({
176
+ amount: 100, // KES — whole numbers only (1–250 000)
177
+ phoneNumber: '0712345678', // any common Kenyan format
178
+ callbackUrl: 'https://yourdomain.com/api/mpesa/callback',
179
+ accountReference: 'INV-001', // max 12 chars
180
+ transactionDesc: 'Subscription', // max 13 chars
181
+ transactionType: 'CustomerPayBillOnline', // default; or "CustomerBuyGoodsOnline"
182
+ partyB: '174379', // defaults to shortCode; set till for Buy Goods
183
+ })
184
+
185
+ console.log(push.CheckoutRequestID) // save this to query later
186
+
187
+ // Query status
188
+ const status = await mpesa.stkQuery({
189
+ checkoutRequestId: push.CheckoutRequestID,
190
+ })
191
+
192
+ if (status.ResultCode === 0) {
193
+ console.log('Payment confirmed!')
194
+ }
195
+
196
+ // Safe variant — returns Result<T> instead of throwing
197
+ const result = await mpesa.stkPushSafe({
198
+ amount: 100,
199
+ phoneNumber: '0712345678',
200
+ callbackUrl: 'https://yourdomain.com/api/mpesa/callback',
201
+ accountReference: 'INV-001',
202
+ transactionDesc: 'Payment',
203
+ })
204
+
205
+ if (result.ok) {
206
+ console.log(result.data.CheckoutRequestID)
207
+ } else {
208
+ console.error(result.error.code, result.error.message)
209
+ }
210
+ ```
211
+
212
+ **Callback payload helpers:**
213
+
214
+ ```typescript
215
+ import {
216
+ isStkCallbackSuccess,
217
+ getCallbackValue,
218
+ type StkPushCallback,
219
+ } from 'pesafy'
220
+
221
+ function handleCallback(body: StkPushCallback) {
222
+ if (isStkCallbackSuccess(body.Body.stkCallback)) {
223
+ const receipt = getCallbackValue(body, 'MpesaReceiptNumber') // string
224
+ const amount = getCallbackValue(body, 'Amount') // number
225
+ const phone = getCallbackValue(body, 'PhoneNumber') // number
226
+ const date = getCallbackValue(body, 'TransactionDate') // number (YYYYMMDDHHmmss)
227
+ }
228
+ }
229
+ ```
230
+
231
+ **STK Push ResultCodes:**
232
+
233
+ | Code | Meaning |
234
+ | ---- | ------------------------ |
235
+ | 0 | Success |
236
+ | 1 | Insufficient balance |
237
+ | 1032 | Cancelled by user |
238
+ | 1037 | DS timeout / unreachable |
239
+ | 2001 | Wrong PIN |
240
+
241
+ ---
242
+
243
+ ### C2B (Customer to Business)
244
+
245
+ Register your Paybill or Till to receive M-PESA payments.
246
+
247
+ ```typescript
248
+ // 1. Register Confirmation + Validation URLs (do this once per shortcode)
249
+ await mpesa.registerC2BUrls({
250
+ shortCode: '600984',
251
+ responseType: 'Completed', // "Completed" | "Cancelled" (sentence-case required)
252
+ confirmationUrl: 'https://yourdomain.com/api/mpesa/c2b/confirmation',
253
+ validationUrl: 'https://yourdomain.com/api/mpesa/c2b/validation',
254
+ apiVersion: 'v2', // default
255
+ })
256
+
257
+ // 2. Simulate (SANDBOX ONLY)
258
+ await mpesa.simulateC2B({
259
+ shortCode: '600984',
260
+ commandId: 'CustomerPayBillOnline', // or "CustomerBuyGoodsOnline"
261
+ amount: 10,
262
+ msisdn: 254708374149,
263
+ billRefNumber: 'INV-001', // Paybill only — omit entirely for Buy Goods
264
+ apiVersion: 'v2',
265
+ })
266
+ ```
267
+
268
+ **Validation webhook handler:**
269
+
270
+ ```typescript
271
+ import {
272
+ acceptC2BValidation,
273
+ rejectC2BValidation,
274
+ isC2BPayload,
275
+ getC2BAmount,
276
+ getC2BTransactionId,
277
+ getC2BCustomerName,
278
+ isPaybillPayment,
279
+ isBuyGoodsPayment,
280
+ type C2BValidationPayload,
281
+ type C2BConfirmationPayload,
282
+ } from 'pesafy'
283
+
284
+ // Validation URL — must respond in ≤8 seconds
285
+ app.post('/api/mpesa/c2b/validation', (req, res) => {
286
+ const payload = req.body as C2BValidationPayload
287
+ const amount = getC2BAmount(payload) // number
288
+
289
+ if (amount > 100_000) {
290
+ return res.json(rejectC2BValidation('C2B00013')) // Invalid Amount
291
+ }
292
+
293
+ // Optionally echo back ThirdPartyTransID for correlation
294
+ res.json(acceptC2BValidation(payload.ThirdPartyTransID))
295
+ })
296
+
297
+ // Confirmation URL — always respond 200 immediately
298
+ app.post('/api/mpesa/c2b/confirmation', (req, res) => {
299
+ const payload = req.body as C2BConfirmationPayload
300
+
301
+ processPayment({
302
+ txId: getC2BTransactionId(payload),
303
+ amount: getC2BAmount(payload),
304
+ name: getC2BCustomerName(payload),
305
+ type: isPaybillPayment(payload) ? 'paybill' : 'till',
306
+ }).catch(console.error)
307
+
308
+ res.json({ ResultCode: 0, ResultDesc: 'Success' })
309
+ })
310
+ ```
311
+
312
+ **C2B Validation ResultCodes:**
313
+
314
+ | Code | Meaning |
315
+ | -------- | ---------------------- |
316
+ | 0 | Accept |
317
+ | C2B00011 | Invalid MSISDN |
318
+ | C2B00012 | Invalid Account Number |
319
+ | C2B00013 | Invalid Amount |
320
+ | C2B00014 | Invalid KYC Details |
321
+ | C2B00015 | Invalid ShortCode |
322
+ | C2B00016 | Other Error |
323
+
324
+ ---
325
+
326
+ ### B2C Account Top Up
327
+
328
+ Moves funds from your MMF/Working account into a B2C disbursement shortcode.
329
+ Only `BusinessPayToBulk` is supported by this API.
330
+
331
+ ```typescript
332
+ const ack = await mpesa.b2cPayment({
333
+ commandId: 'BusinessPayToBulk', // only valid CommandID for this API
334
+ amount: 50_000,
335
+ partyA: '600979', // sender MMF shortcode (or set b2cPartyA in config)
336
+ partyB: '600000', // target B2C shortcode
337
+ accountReference: 'BATCH-2024-01',
338
+ resultUrl: 'https://yourdomain.com/api/mpesa/b2c/result',
339
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/b2c/timeout',
340
+ remarks: 'Monthly top-up',
341
+ })
342
+ ```
343
+
344
+ **B2C Result webhook handler:**
345
+
346
+ ```typescript
347
+ import {
348
+ isB2CResult,
349
+ isB2CSuccess,
350
+ isB2CFailure,
351
+ getB2CTransactionId,
352
+ getB2CAmount,
353
+ getB2CConversationId,
354
+ getB2COriginatorConversationId,
355
+ getB2CReceiverPublicName,
356
+ getB2CDebitAccountBalance,
357
+ getB2CDebitPartyCharges,
358
+ getB2CCurrency,
359
+ getB2CTransactionCompletedTime,
360
+ } from 'pesafy'
361
+
362
+ app.post('/api/mpesa/b2c/result', (req, res) => {
363
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' }) // always respond 200 first
364
+
365
+ if (!isB2CResult(req.body)) return
366
+
367
+ if (isB2CSuccess(req.body)) {
368
+ console.log('B2C success:', {
369
+ txId: getB2CTransactionId(req.body),
370
+ amount: getB2CAmount(req.body),
371
+ balance: getB2CDebitAccountBalance(req.body),
372
+ })
373
+ } else {
374
+ console.error('B2C failed:', req.body.Result.ResultDesc)
375
+ }
376
+ })
377
+ ```
378
+
379
+ ---
380
+
381
+ ### B2C Disbursement
382
+
383
+ Directly disburses funds to individual customer M-PESA wallets. Supports
384
+ `BusinessPayment`, `SalaryPayment`, and `PromotionPayment`.
385
+
386
+ ```typescript
387
+ const ack = await mpesa.b2cDisbursement({
388
+ originatorConversationId: 'unique-uuid-per-request', // required for idempotency
389
+ commandId: 'SalaryPayment', // "BusinessPayment" | "SalaryPayment" | "PromotionPayment"
390
+ amount: 2500,
391
+ partyA: '600979', // sender shortcode
392
+ partyB: '254712345678', // recipient MSISDN (2547XXXXXXXX)
393
+ remarks: 'January salary', // required, 2–100 chars
394
+ resultUrl: 'https://yourdomain.com/api/mpesa/b2c/disburse/result',
395
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/b2c/disburse/timeout',
396
+ occasion: 'Payroll Jan 2024', // optional
397
+ })
398
+ ```
399
+
400
+ **B2C Disbursement result handler:**
401
+
402
+ ```typescript
403
+ import {
404
+ isB2CDisbursementResult,
405
+ isB2CDisbursementSuccess,
406
+ isB2CDisbursementRecipientRegistered,
407
+ getB2CDisbursementReceiptNumber,
408
+ getB2CDisbursementAmount,
409
+ getB2CDisbursementReceiverName,
410
+ getB2CDisbursementUtilityBalance,
411
+ getB2CDisbursementWorkingBalance,
412
+ getB2CDisbursementCompletedTime,
413
+ } from 'pesafy'
414
+
415
+ app.post('/api/mpesa/b2c/disburse/result', (req, res) => {
416
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
417
+
418
+ if (!isB2CDisbursementResult(req.body)) return
419
+
420
+ if (isB2CDisbursementSuccess(req.body)) {
421
+ console.log({
422
+ receipt: getB2CDisbursementReceiptNumber(req.body),
423
+ amount: getB2CDisbursementAmount(req.body),
424
+ receiver: getB2CDisbursementReceiverName(req.body),
425
+ registered: isB2CDisbursementRecipientRegistered(req.body), // boolean
426
+ utility: getB2CDisbursementUtilityBalance(req.body),
427
+ working: getB2CDisbursementWorkingBalance(req.body),
428
+ completedAt: getB2CDisbursementCompletedTime(req.body),
429
+ })
430
+ }
431
+ })
432
+ ```
433
+
434
+ **B2C Disbursement CommandIDs:**
435
+
436
+ | CommandID | Use Case |
437
+ | ------------------ | ----------------------------- |
438
+ | `BusinessPayment` | Unsecured payment to customer |
439
+ | `SalaryPayment` | Salary disbursement |
440
+ | `PromotionPayment` | Promotions / bonus |
441
+
442
+ ---
443
+
444
+ ### B2B Express Checkout
445
+
446
+ Sends a USSD Push to a merchant's till — merchant approves by entering their
447
+ M-PESA PIN.
448
+
449
+ ```typescript
450
+ const ack = await mpesa.b2bExpressCheckout({
451
+ primaryShortCode: '000001', // merchant till (debit party)
452
+ receiverShortCode: '000002', // vendor Paybill (credit party)
453
+ amount: 5000,
454
+ paymentRef: 'INV-001',
455
+ callbackUrl: 'https://yourdomain.com/api/mpesa/b2b/callback',
456
+ partnerName: 'Acme Supplies', // shown in merchant's USSD prompt
457
+ requestRefId: 'unique-uuid', // auto-generated UUID if omitted
458
+ })
459
+
460
+ // ack.code === "0" → USSD push initiated successfully
461
+ ```
462
+
463
+ **B2B Callback handler:**
464
+
465
+ ```typescript
466
+ import {
467
+ isB2BCheckoutCallback,
468
+ isB2BCheckoutSuccess,
469
+ isB2BCheckoutCancelled,
470
+ isB2BCheckoutFailed,
471
+ getB2BTransactionId,
472
+ getB2BAmount,
473
+ getB2BConversationId,
474
+ getB2BPaymentReference,
475
+ getB2BResultCode,
476
+ getB2BRequestId,
477
+ } from 'pesafy'
478
+
479
+ app.post('/api/mpesa/b2b/callback', (req, res) => {
480
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
481
+
482
+ if (!isB2BCheckoutCallback(req.body)) return
483
+
484
+ if (isB2BCheckoutSuccess(req.body)) {
485
+ console.log('B2B paid:', {
486
+ txId: getB2BTransactionId(req.body),
487
+ amount: getB2BAmount(req.body),
488
+ convId: getB2BConversationId(req.body),
489
+ })
490
+ } else if (isB2BCheckoutCancelled(req.body)) {
491
+ console.log('Cancelled, ref:', getB2BPaymentReference(req.body))
492
+ } else {
493
+ console.warn('B2B failed, code:', getB2BResultCode(req.body))
494
+ }
495
+ })
496
+ ```
497
+
498
+ **B2B Express result codes:**
499
+
500
+ | Code | Meaning |
501
+ | ---- | ------------------------ |
502
+ | 0 | Success |
503
+ | 4001 | User cancelled |
504
+ | 4102 | Merchant KYC fail |
505
+ | 4104 | Missing Nominated Number |
506
+ | 4201 | USSD network error |
507
+ | 4203 | USSD exception error |
508
+
509
+ ---
510
+
511
+ ### B2B Pay Bill
512
+
513
+ Moves money from your MMF/Working account to another organisation's utility
514
+ account.
515
+
516
+ ```typescript
517
+ const ack = await mpesa.b2bPayBill({
518
+ commandId: 'BusinessPayBill', // only valid CommandID
519
+ amount: 10_000,
520
+ partyA: '600979', // your shortcode (debit)
521
+ partyB: '000000', // destination Paybill (credit)
522
+ accountReference: 'ACC-353353', // max 13 chars
523
+ resultUrl: 'https://yourdomain.com/api/mpesa/b2b/paybill/result',
524
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/b2b/paybill/timeout',
525
+ remarks: 'Supplier payment',
526
+ requester: '254712345678', // optional — consumer MSISDN on whose behalf
527
+ occasion: 'Q1 Invoice', // optional
528
+ })
529
+ ```
530
+
531
+ **B2B Pay Bill result handler:**
532
+
533
+ ```typescript
534
+ import {
535
+ isB2BPayBillResult,
536
+ isB2BPayBillSuccess,
537
+ getB2BPayBillTransactionId,
538
+ getB2BPayBillAmount,
539
+ getB2BPayBillReceiverName,
540
+ getB2BPayBillDebitPartyAffectedBalance,
541
+ getB2BPayBillBillReferenceNumber,
542
+ getB2BPayBillCompletedTime,
543
+ } from 'pesafy'
544
+
545
+ app.post('/api/mpesa/b2b/paybill/result', (req, res) => {
546
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
547
+ if (!isB2BPayBillResult(req.body)) return
548
+
549
+ if (isB2BPayBillSuccess(req.body)) {
550
+ console.log({
551
+ txId: getB2BPayBillTransactionId(req.body),
552
+ amount: getB2BPayBillAmount(req.body),
553
+ to: getB2BPayBillReceiverName(req.body),
554
+ balance: getB2BPayBillDebitPartyAffectedBalance(req.body),
555
+ billRef: getB2BPayBillBillReferenceNumber(req.body),
556
+ time: getB2BPayBillCompletedTime(req.body),
557
+ })
558
+ }
559
+ })
560
+ ```
561
+
562
+ ---
563
+
564
+ ### B2B Buy Goods
565
+
566
+ Moves money from your MMF/Working account to a merchant's till/store account.
567
+
568
+ ```typescript
569
+ const ack = await mpesa.b2bBuyGoods({
570
+ commandId: 'BusinessBuyGoods', // only valid CommandID
571
+ amount: 5_000,
572
+ partyA: '600979', // your shortcode (debit)
573
+ partyB: '000000', // destination till / merchant store
574
+ accountReference: 'PO-19008', // max 13 chars
575
+ resultUrl: 'https://yourdomain.com/api/mpesa/b2b/buygoods/result',
576
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/b2b/buygoods/timeout',
577
+ remarks: 'Stock purchase',
578
+ requester: '254712345678', // optional
579
+ })
580
+ ```
581
+
582
+ **B2B Buy Goods result handler:**
583
+
584
+ ```typescript
585
+ import {
586
+ isB2BBuyGoodsResult,
587
+ isB2BBuyGoodsSuccess,
588
+ getB2BBuyGoodsTransactionId,
589
+ getB2BBuyGoodsAmount,
590
+ getB2BBuyGoodsReceiverName,
591
+ getB2BBuyGoodsDebitPartyAffectedBalance,
592
+ getB2BBuyGoodsBillReferenceNumber,
593
+ getB2BBuyGoodsCompletedTime,
594
+ getB2BBuyGoodsCurrency,
595
+ } from 'pesafy'
596
+
597
+ app.post('/api/mpesa/b2b/buygoods/result', (req, res) => {
598
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
599
+ if (!isB2BBuyGoodsResult(req.body)) return
600
+
601
+ if (isB2BBuyGoodsSuccess(req.body)) {
602
+ console.log({
603
+ txId: getB2BBuyGoodsTransactionId(req.body),
604
+ amount: getB2BBuyGoodsAmount(req.body),
605
+ currency: getB2BBuyGoodsCurrency(req.body),
606
+ to: getB2BBuyGoodsReceiverName(req.body),
607
+ balance: getB2BBuyGoodsDebitPartyAffectedBalance(req.body),
608
+ billRef: getB2BBuyGoodsBillReferenceNumber(req.body),
609
+ time: getB2BBuyGoodsCompletedTime(req.body),
610
+ })
611
+ }
612
+ })
613
+ ```
614
+
615
+ **B2B result codes (Pay Bill & Buy Goods share the same set):**
616
+
617
+ | Code | Meaning |
618
+ | ---- | ------------------------ |
619
+ | 0 | Success |
620
+ | 1 | Insufficient balance |
621
+ | 2 | Amount below minimum |
622
+ | 3 | Amount above maximum |
623
+ | 4 | Daily limit exceeded |
624
+ | 8 | Maximum balance exceeded |
625
+ | 2001 | Invalid initiator info |
626
+ | 2006 | Account inactive |
627
+ | 2028 | Product not permitted |
628
+ | 2040 | Customer not registered |
629
+
630
+ ---
631
+
632
+ ### Account Balance
633
+
634
+ Queries the balance of your M-PESA shortcode. **Asynchronous** — result is
635
+ POSTed to your `resultUrl`.
636
+
637
+ ```typescript
638
+ await mpesa.accountBalance({
639
+ partyA: '174379',
640
+ identifierType: '4', // "1"=MSISDN, "2"=Till, "4"=ShortCode (most common)
641
+ resultUrl: 'https://yourdomain.com/api/mpesa/balance/result',
642
+ queueTimeOutUrl:'https://yourdomain.com/api/mpesa/balance/timeout',
643
+ remarks: 'Balance check',
644
+ })
645
+
646
+ // Safe variant
647
+ const result = await mpesa.accountBalanceSafe({ ... })
648
+ ```
649
+
650
+ > **Required org portal role:** "Balance Query ORG API"
651
+
652
+ **Parsing the result callback:**
653
+
654
+ ```typescript
655
+ import {
656
+ isAccountBalanceSuccess,
657
+ parseAccountBalance,
658
+ getAccountBalanceParam,
659
+ getAccountBalanceRawBalance,
660
+ getAccountBalanceTransactionId,
661
+ getAccountBalanceCompletedTime,
662
+ type AccountBalanceResult,
663
+ } from 'pesafy'
664
+
665
+ app.post('/api/mpesa/balance/result', (req, res) => {
666
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
667
+
668
+ const body = req.body as AccountBalanceResult
669
+ if (!isAccountBalanceSuccess(body)) return
670
+
671
+ const raw = getAccountBalanceRawBalance(body)
672
+ const accounts = parseAccountBalance(raw ?? '')
673
+
674
+ for (const account of accounts) {
675
+ // e.g. { name: "Working Account", currency: "KES", amount: "45000.00" }
676
+ console.log(`${account.name}: ${account.currency} ${account.amount}`)
677
+ }
678
+
679
+ console.log('Completed at:', getAccountBalanceCompletedTime(body))
680
+ })
681
+ ```
682
+
683
+ ---
684
+
685
+ ### Transaction Status
686
+
687
+ Queries the result of any completed M-PESA transaction. **Asynchronous**.
688
+
689
+ ```typescript
690
+ await mpesa.transactionStatus({
691
+ transactionId: 'OEI2AK4XXXX', // M-Pesa receipt number
692
+ // OR: originalConversationId: '7071-4170-...' ← when no receipt is available
693
+ partyA: '174379',
694
+ identifierType: '4', // "1"=MSISDN, "2"=Till, "4"=ShortCode
695
+ resultUrl: 'https://yourdomain.com/api/mpesa/tx/result',
696
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/tx/timeout',
697
+ remarks: 'Check payment status',
698
+ })
699
+ ```
700
+
701
+ **Transaction status result handler:**
702
+
703
+ ```typescript
704
+ import {
705
+ isTransactionStatusResult,
706
+ isTransactionStatusSuccess,
707
+ getTransactionStatusReceiptNo,
708
+ getTransactionStatusAmount,
709
+ getTransactionStatusStatus,
710
+ getTransactionStatusDebitPartyName,
711
+ getTransactionStatusCreditPartyName,
712
+ getTransactionStatusTransactionDate,
713
+ } from 'pesafy'
714
+
715
+ app.post('/api/mpesa/tx/result', (req, res) => {
716
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
717
+ if (!isTransactionStatusResult(req.body)) return
718
+
719
+ if (isTransactionStatusSuccess(req.body)) {
720
+ console.log({
721
+ receipt: getTransactionStatusReceiptNo(req.body),
722
+ amount: getTransactionStatusAmount(req.body),
723
+ status: getTransactionStatusStatus(req.body), // e.g. "Completed"
724
+ from: getTransactionStatusDebitPartyName(req.body),
725
+ to: getTransactionStatusCreditPartyName(req.body),
726
+ date: getTransactionStatusTransactionDate(req.body),
727
+ })
728
+ }
729
+ })
730
+ ```
731
+
732
+ ---
733
+
734
+ ### Transaction Reversal
735
+
736
+ Reverses a completed M-PESA C2B transaction. **Asynchronous**.
737
+
738
+ > **Note:** `RecieverIdentifierType` is always `"11"` for reversals (per Daraja
739
+ > docs). B2C reversals must be done manually on the M-PESA organisation portal.
740
+ >
741
+ > **Required org portal role:** "Org Reversals Initiator"
742
+
743
+ ```typescript
744
+ await mpesa.reverseTransaction({
745
+ transactionId: 'OEI2AK4XXXX', // M-PESA receipt of the transaction to reverse
746
+ receiverParty: '174379', // your shortcode
747
+ amount: 500, // must equal the original amount
748
+ resultUrl: 'https://yourdomain.com/api/mpesa/reversal/result',
749
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/reversal/timeout',
750
+ remarks: 'Erroneous charge', // 2–100 chars
751
+ })
752
+ ```
753
+
754
+ **Reversal result handler:**
755
+
756
+ ```typescript
757
+ import {
758
+ isReversalResult,
759
+ isReversalSuccess,
760
+ getReversalTransactionId,
761
+ getReversalOriginalTransactionId,
762
+ getReversalAmount,
763
+ getReversalCreditPartyPublicName,
764
+ getReversalDebitPartyPublicName,
765
+ getReversalCompletedTime,
766
+ getReversalCharge,
767
+ REVERSAL_RESULT_CODES,
768
+ } from 'pesafy'
769
+
770
+ app.post('/api/mpesa/reversal/result', (req, res) => {
771
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
772
+ if (!isReversalResult(req.body)) return
773
+
774
+ if (isReversalSuccess(req.body)) {
775
+ console.log('Reversed:', {
776
+ newTxId: getReversalTransactionId(req.body),
777
+ origTxId: getReversalOriginalTransactionId(req.body),
778
+ amount: getReversalAmount(req.body),
779
+ to: getReversalCreditPartyPublicName(req.body),
780
+ from: getReversalDebitPartyPublicName(req.body),
781
+ time: getReversalCompletedTime(req.body),
782
+ charge: getReversalCharge(req.body),
783
+ })
784
+ } else {
785
+ const code = req.body.Result.ResultCode
786
+ // REVERSAL_RESULT_CODES.ALREADY_REVERSED = "R000001"
787
+ // REVERSAL_RESULT_CODES.INVALID_TRANSACTION_ID = "R000002"
788
+ console.warn('Reversal failed, code:', code)
789
+ }
790
+ })
791
+ ```
792
+
793
+ **Reversal result codes:**
794
+
795
+ | Code | Meaning |
796
+ | ------- | ----------------------------------------- |
797
+ | 0 | Success |
798
+ | 1 | Insufficient balance |
799
+ | 11 | DebitParty in invalid state |
800
+ | 21 | Initiator not allowed |
801
+ | 2001 | Initiator information invalid |
802
+ | 2006 | Account inactive |
803
+ | 2028 | Not permitted |
804
+ | 8006 | Security credential locked |
805
+ | R000001 | Transaction already reversed |
806
+ | R000002 | OriginalTransactionID invalid / not found |
807
+
808
+ ---
809
+
810
+ ### Tax Remittance (KRA)
811
+
812
+ Remits tax to Kenya Revenue Authority via M-PESA. **Asynchronous**. `PartyB` is
813
+ always KRA's shortcode `"572572"` — set automatically.
814
+
815
+ ```typescript
816
+ await mpesa.remitTax({
817
+ amount: 5_000,
818
+ partyA: '888880', // your business shortcode
819
+ accountReference: 'PRN1234XN', // KRA Payment Registration Number (PRN)
820
+ resultUrl: 'https://yourdomain.com/api/mpesa/tax/result',
821
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/tax/timeout',
822
+ remarks: 'Monthly PAYE',
823
+ })
824
+ ```
825
+
826
+ **Tax result handler:**
827
+
828
+ ```typescript
829
+ import {
830
+ isTaxRemittanceResult,
831
+ isTaxRemittanceSuccess,
832
+ getTaxTransactionId,
833
+ getTaxAmount,
834
+ getTaxReceiverName,
835
+ getTaxCompletedTime,
836
+ } from 'pesafy'
837
+
838
+ app.post('/api/mpesa/tax/result', (req, res) => {
839
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
840
+ if (!isTaxRemittanceResult(req.body)) return
841
+
842
+ if (isTaxRemittanceSuccess(req.body)) {
843
+ console.log({
844
+ txId: getTaxTransactionId(req.body),
845
+ amount: getTaxAmount(req.body),
846
+ to: getTaxReceiverName(req.body),
847
+ time: getTaxCompletedTime(req.body),
848
+ })
849
+ }
850
+ })
851
+ ```
852
+
853
+ ---
854
+
855
+ ### Dynamic QR Code
856
+
857
+ Generates an M-PESA QR code customers scan to pay.
858
+
859
+ ```typescript
860
+ const qr = await mpesa.generateDynamicQR({
861
+ merchantName: 'My Shop',
862
+ refNo: 'INV-001',
863
+ amount: 500,
864
+ trxCode: 'BG', // see table below
865
+ cpi: '373132', // till / paybill / MSISDN depending on trxCode
866
+ size: 300, // pixels (1–1000, default 300)
867
+ })
868
+
869
+ // Render in HTML:
870
+ // <img src={`data:image/png;base64,${qr.QRCode}`} />
871
+
872
+ // Write to disk:
873
+ // import { writeFileSync } from 'node:fs'
874
+ // writeFileSync('qr.png', Buffer.from(qr.QRCode, 'base64'))
875
+ ```
876
+
877
+ **QR Transaction codes:**
878
+
879
+ | Code | Use Case | CPI format |
880
+ | ---- | --------------------------- | --------------- |
881
+ | `BG` | Pay Merchant (Buy Goods) | Till number |
882
+ | `WA` | Withdraw Cash at Agent Till | Agent till |
883
+ | `PB` | Paybill / Business number | Paybill number |
884
+ | `SM` | Send Money (mobile number) | Customer MSISDN |
885
+ | `SB` | Send to Business | MSISDN-format |
886
+
887
+ ---
888
+
889
+ ### Bill Manager
890
+
891
+ Create and send invoices that customers pay directly via M-PESA.
892
+
893
+ ```typescript
894
+ // 1. Opt-in your shortcode (one-time setup)
895
+ await mpesa.billManagerOptIn({
896
+ shortcode: '600984',
897
+ email: 'billing@company.com',
898
+ officialContact: '0700000000',
899
+ sendReminders: '1', // "1" enable | "0" disable (7-, 3-day, due-date reminders)
900
+ logo: 'https://cdn.company.com/logo.jpg', // optional JPEG/JPG
901
+ callbackUrl: 'https://yourdomain.com/api/mpesa/bills/callback',
902
+ })
903
+
904
+ // 2. Update opt-in details
905
+ await mpesa.updateOptIn({
906
+ shortcode: '600984',
907
+ email: 'new@company.com',
908
+ officialContact: '0700000001',
909
+ sendReminders: '1',
910
+ callbackUrl: 'https://yourdomain.com/api/mpesa/bills/callback',
911
+ })
912
+
913
+ // 3. Send a single invoice
914
+ await mpesa.sendInvoice({
915
+ externalReference: 'INV-001', // your unique invoice ID
916
+ billedFullName: 'John Doe', // shown in customer SMS
917
+ billedPhoneNumber: '254712345678', // Safaricom number to receive SMS
918
+ billedPeriod: 'January 2024', // e.g. "August 2021"
919
+ invoiceName: 'Monthly Subscription',
920
+ dueDate: '2024-01-31', // YYYY-MM-DD or YYYY-MM-DD HH:MM:SS
921
+ accountReference: 'ACC-12345',
922
+ amount: 2500,
923
+ invoiceItems: [
924
+ { itemName: 'Base subscription', amount: 2000 },
925
+ { itemName: 'SMS bundle', amount: 500 },
926
+ ],
927
+ })
928
+
929
+ // 4. Bulk invoices (up to 1 000 per call)
930
+ await mpesa.sendBulkInvoices({
931
+ invoices: [
932
+ {
933
+ externalReference: 'INV-002',
934
+ billedFullName: 'Jane Smith',
935
+ billedPhoneNumber: '254700000000',
936
+ billedPeriod: 'January 2024',
937
+ invoiceName: 'Monthly Subscription',
938
+ dueDate: '2024-01-31',
939
+ accountReference: 'ACC-67890',
940
+ amount: 2500,
941
+ },
942
+ ],
943
+ })
944
+
945
+ // 5. Cancel a single invoice (cannot cancel partially/fully paid invoices)
946
+ await mpesa.cancelInvoice({ externalReference: 'INV-001' })
947
+
948
+ // 6. Cancel multiple invoices
949
+ await mpesa.cancelBulkInvoices({ externalReferences: ['INV-002', 'INV-003'] })
950
+
951
+ // 7. Reconcile after processing a payment notification
952
+ await mpesa.reconcilePayment({
953
+ transactionId: 'RJB53MYR1N',
954
+ externalReference: 'INV-001',
955
+ accountReference: 'ACC-12345',
956
+ paidAmount: '2500',
957
+ paymentDate: '2024-01-15',
958
+ phoneNumber: '0712345678',
959
+ fullName: 'John Doe',
960
+ invoiceName: 'Monthly Subscription',
961
+ })
962
+ ```
963
+
964
+ **Payment notification callback (POSTed to your `callbackUrl`):**
965
+
966
+ ```typescript
967
+ import { type BillManagerPaymentNotification } from 'pesafy'
968
+
969
+ app.post('/api/mpesa/bills/callback', (req, res) => {
970
+ const notification = req.body as BillManagerPaymentNotification
971
+ console.log({
972
+ txId: notification.transactionId,
973
+ amount: notification.paidAmount,
974
+ phone: notification.msisdn,
975
+ accountRef: notification.accountReference,
976
+ shortCode: notification.shortCode,
977
+ date: notification.dateCreated,
978
+ })
979
+ res.json({ rescode: '200', resmsg: 'Success' })
980
+ })
981
+ ```
982
+
983
+ ---
984
+
985
+ ### Webhooks
986
+
987
+ **IP verification** — Safaricom always calls from a fixed whitelist:
988
+
989
+ ```typescript
990
+ import { verifyWebhookIP, SAFARICOM_IPS } from 'pesafy'
991
+
992
+ app.post('/api/mpesa/callback', (req, res) => {
993
+ const ip = (req.headers['x-forwarded-for'] as string) ?? req.ip ?? ''
994
+ if (!verifyWebhookIP(ip)) {
995
+ console.warn('Callback from unknown IP:', ip)
996
+ // Still respond 200 — Safaricom retries on non-200
997
+ }
998
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
999
+ })
1000
+ ```
1001
+
1002
+ **Generic STK Push webhook handler:**
1003
+
1004
+ ```typescript
1005
+ import {
1006
+ handleWebhook,
1007
+ isSuccessfulCallback,
1008
+ extractTransactionId,
1009
+ extractAmount,
1010
+ extractPhoneNumber,
1011
+ } from 'pesafy'
1012
+
1013
+ app.post('/api/mpesa/callback', (req, res) => {
1014
+ const result = handleWebhook(req.body, {
1015
+ requestIP: req.ip,
1016
+ skipIPCheck: false, // set true in local dev
1017
+ })
1018
+
1019
+ if (result.success && isSuccessfulCallback(result.data)) {
1020
+ const receipt = extractTransactionId(result.data)
1021
+ const amount = extractAmount(result.data)
1022
+ const phone = extractPhoneNumber(result.data)
1023
+ }
1024
+
1025
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
1026
+ })
1027
+ ```
1028
+
1029
+ **Retry with backoff** (for your own async processing):
1030
+
1031
+ ```typescript
1032
+ import { retryWithBackoff } from 'pesafy'
1033
+
1034
+ const outcome = await retryWithBackoff(() => saveToDatabase(webhookData), {
1035
+ maxRetries: 5,
1036
+ initialDelay: 500,
1037
+ maxDelay: 30_000,
1038
+ backoffMultiplier: 2,
1039
+ })
1040
+
1041
+ if (!outcome.success) {
1042
+ console.error(
1043
+ `Failed after ${outcome.attempts} attempts:`,
1044
+ outcome.error?.message,
1045
+ )
1046
+ }
1047
+ ```
1048
+
1049
+ ---
1050
+
1051
+ ## Framework Adapters
1052
+
1053
+ ### Express Adapter
1054
+
1055
+ ```typescript
1056
+ import express from 'express'
1057
+ import { createMpesaExpressRouter } from 'pesafy/adapters/express'
1058
+ import { acceptC2BValidation } from 'pesafy'
1059
+
1060
+ const app = express()
1061
+ const router = express.Router()
1062
+ app.use(express.json())
1063
+
1064
+ createMpesaExpressRouter(router, {
1065
+ // Core
1066
+ consumerKey: process.env.MPESA_CONSUMER_KEY!,
1067
+ consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
1068
+ environment: 'sandbox',
1069
+
1070
+ // STK Push
1071
+ lipaNaMpesaShortCode: '174379',
1072
+ lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
1073
+ callbackUrl: 'https://yourdomain.com/api/mpesa/express/callback',
1074
+
1075
+ // Initiator (B2C, Tax, Reversal, Balance)
1076
+ initiatorName: 'testapi',
1077
+ initiatorPassword: 'Safaricom123!',
1078
+ certificatePath: './SandboxCertificate.cer',
1079
+
1080
+ // Transaction Status
1081
+ resultUrl: 'https://yourdomain.com/api/mpesa/transaction-status/result',
1082
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/timeout',
1083
+
1084
+ // C2B
1085
+ c2bShortCode: '600984',
1086
+ c2bConfirmationUrl: 'https://yourdomain.com/api/mpesa/c2b/confirmation',
1087
+ c2bValidationUrl: 'https://yourdomain.com/api/mpesa/c2b/validation',
1088
+ c2bResponseType: 'Completed',
1089
+ c2bApiVersion: 'v2',
1090
+ onC2BValidation: async (payload) => {
1091
+ if (Number(payload.TransAmount) > 100_000)
1092
+ return { ResultCode: 'C2B00013', ResultDesc: 'Rejected' }
1093
+ return acceptC2BValidation()
1094
+ },
1095
+ onC2BConfirmation: async (payload) => {
1096
+ await db.payments.create({
1097
+ txId: payload.TransID,
1098
+ amount: Number(payload.TransAmount),
1099
+ })
1100
+ },
1101
+
1102
+ // Tax Remittance
1103
+ taxPartyA: '888880',
1104
+ taxResultUrl: 'https://yourdomain.com/api/mpesa/tax/result',
1105
+ taxQueueTimeOutUrl: 'https://yourdomain.com/api/mpesa/tax/timeout',
1106
+ onTaxRemittanceResult: async (result) => {
1107
+ console.log('Tax result:', result.Result.ResultCode)
1108
+ },
1109
+
1110
+ // B2B Express Checkout
1111
+ b2bReceiverShortCode: '000002',
1112
+ b2bCallbackUrl: 'https://yourdomain.com/api/mpesa/b2b/callback',
1113
+ onB2BCheckoutCallback: async (callback) => {
1114
+ console.log('B2B callback:', callback.resultCode)
1115
+ },
1116
+
1117
+ // B2C Account Top Up
1118
+ b2cPartyA: '600979',
1119
+ b2cResultUrl: 'https://yourdomain.com/api/mpesa/b2c/result',
1120
+ b2cQueueTimeOutUrl: 'https://yourdomain.com/api/mpesa/b2c/timeout',
1121
+ onB2CResult: async (result) => {
1122
+ console.log('B2C result:', result.Result.ResultCode)
1123
+ },
1124
+
1125
+ skipIPCheck: true, // local dev only
1126
+ })
1127
+
1128
+ app.use('/api', router)
1129
+ app.listen(3000)
1130
+ ```
1131
+
1132
+ **Routes mounted by `createMpesaExpressRouter`:**
1133
+
1134
+ | Method | Path | Description |
1135
+ | ------ | ---------------------------------- | -------------------------------- |
1136
+ | POST | `/mpesa/express/stk-push` | Initiate STK Push |
1137
+ | POST | `/mpesa/express/stk-query` | Query STK Push status |
1138
+ | POST | `/mpesa/express/callback` | STK Push callback from Safaricom |
1139
+ | POST | `/mpesa/transaction-status/query` | Query transaction status |
1140
+ | POST | `/mpesa/transaction-status/result` | Transaction status result |
1141
+ | POST | `/mpesa/c2b/register-url` | Register C2B URLs |
1142
+ | POST | `/mpesa/c2b/simulate` | Simulate C2B (sandbox only) |
1143
+ | POST | `/mpesa/c2b/validation` | C2B validation callback |
1144
+ | POST | `/mpesa/c2b/confirmation` | C2B confirmation callback |
1145
+ | POST | `/mpesa/tax/remit` | Initiate tax remittance |
1146
+ | POST | `/mpesa/tax/result` | Tax remittance result |
1147
+ | POST | `/mpesa/b2b/checkout` | B2B Express Checkout |
1148
+ | POST | `/mpesa/b2b/callback` | B2B Express Checkout callback |
1149
+ | POST | `/mpesa/b2c/payment` | B2C Account Top Up |
1150
+ | POST | `/mpesa/b2c/result` | B2C result callback |
1151
+
1152
+ ---
1153
+
1154
+ ### Hono Adapter
1155
+
1156
+ Works on Bun, Cloudflare Workers, Deno, and Node.js.
1157
+
1158
+ ```typescript
1159
+ import { Hono } from 'hono'
1160
+ import { createMpesaHonoRouter } from 'pesafy/adapters/hono'
1161
+
1162
+ const app = new Hono()
1163
+
1164
+ createMpesaHonoRouter(app, {
1165
+ consumerKey: process.env.MPESA_CONSUMER_KEY!,
1166
+ consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
1167
+ environment: 'sandbox',
1168
+ lipaNaMpesaShortCode: '174379',
1169
+ lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
1170
+ callbackUrl: 'https://yourdomain.com/mpesa/express/callback',
1171
+ resultUrl: 'https://yourdomain.com/mpesa/result',
1172
+ queueTimeOutUrl: 'https://yourdomain.com/mpesa/timeout',
1173
+
1174
+ onStkSuccess: async ({ receiptNumber, amount, phone }) => {
1175
+ await db.payments.create({ receiptNumber, amount, phone })
1176
+ },
1177
+ onStkFailure: ({ resultCode, resultDesc }) => {
1178
+ console.warn('STK failed:', resultCode, resultDesc)
1179
+ },
1180
+
1181
+ onAccountBalanceResult: async (body) => {
1182
+ console.log('Balance result:', body)
1183
+ },
1184
+ onReversalResult: async (body) => {
1185
+ console.log('Reversal result:', body)
1186
+ },
1187
+
1188
+ skipIPCheck: true, // local dev only
1189
+ })
1190
+
1191
+ // Bun
1192
+ export default app
1193
+
1194
+ // Cloudflare Workers
1195
+ export default { fetch: app.fetch }
1196
+ ```
1197
+
1198
+ **Routes mounted by `createMpesaHonoRouter`:**
1199
+
1200
+ | Method | Path | Description |
1201
+ | ------ | -------------------------- | -------------------------------- |
1202
+ | POST | `/mpesa/express/stk-push` | Initiate STK Push |
1203
+ | POST | `/mpesa/express/stk-query` | Query STK Push status |
1204
+ | POST | `/mpesa/express/callback` | STK Push callback from Safaricom |
1205
+ | POST | `/mpesa/balance/query` | Account balance query |
1206
+ | POST | `/mpesa/balance/result` | Account balance result |
1207
+ | POST | `/mpesa/reversal/request` | Reversal request |
1208
+ | POST | `/mpesa/reversal/result` | Reversal result callback |
1209
+
1210
+ ---
1211
+
1212
+ ### Next.js Adapter
1213
+
1214
+ ```typescript
1215
+ // app/api/mpesa/stk-push/route.ts
1216
+ import { createStkPushHandler } from 'pesafy/adapters/nextjs'
1217
+
1218
+ export const POST = createStkPushHandler({
1219
+ consumerKey: process.env.MPESA_CONSUMER_KEY!,
1220
+ consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
1221
+ environment: 'sandbox',
1222
+ lipaNaMpesaShortCode: process.env.MPESA_SHORTCODE!,
1223
+ lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
1224
+ callbackUrl: process.env.MPESA_CALLBACK_URL!,
1225
+ })
1226
+ ```
1227
+
1228
+ ```typescript
1229
+ // app/api/mpesa/callback/route.ts
1230
+ import { createStkCallbackHandler } from 'pesafy/adapters/nextjs'
1231
+
1232
+ export const POST = createStkCallbackHandler({
1233
+ consumerKey: process.env.MPESA_CONSUMER_KEY!,
1234
+ consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
1235
+ environment: 'sandbox',
1236
+ callbackUrl: process.env.MPESA_CALLBACK_URL!,
1237
+ onSuccess: async ({ receiptNumber, amount, phone }) => {
1238
+ await db.payments.create({
1239
+ receiptNumber,
1240
+ amount: amount ?? 0,
1241
+ phone: phone ?? '',
1242
+ })
1243
+ },
1244
+ onFailure: ({ resultCode, resultDesc }) => {
1245
+ console.warn('Payment failed:', resultCode, resultDesc)
1246
+ },
1247
+ skipIPCheck: true,
1248
+ })
1249
+ ```
1250
+
1251
+ ```typescript
1252
+ // app/api/mpesa/stk-query/route.ts
1253
+ import { createStkQueryHandler } from 'pesafy/adapters/nextjs'
1254
+
1255
+ export const POST = createStkQueryHandler({
1256
+ consumerKey: process.env.MPESA_CONSUMER_KEY!,
1257
+ consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
1258
+ environment: 'sandbox',
1259
+ lipaNaMpesaShortCode: process.env.MPESA_SHORTCODE!,
1260
+ lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
1261
+ callbackUrl: process.env.MPESA_CALLBACK_URL!,
1262
+ })
1263
+ ```
1264
+
1265
+ **Catch-all bundle** (single route file for all handlers):
1266
+
1267
+ ```typescript
1268
+ // app/api/mpesa/[[...route]]/route.ts
1269
+ import { createMpesaNextHandlers } from 'pesafy/adapters/nextjs'
1270
+
1271
+ export const { POST } = createMpesaNextHandlers({
1272
+ consumerKey: process.env.MPESA_CONSUMER_KEY!,
1273
+ consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
1274
+ environment: 'sandbox',
1275
+ lipaNaMpesaShortCode: process.env.MPESA_SHORTCODE!,
1276
+ lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
1277
+ callbackUrl: process.env.MPESA_CALLBACK_URL!,
1278
+ resultUrl: process.env.MPESA_RESULT_URL,
1279
+ queueTimeOutUrl: process.env.MPESA_QUEUE_TIMEOUT_URL,
1280
+ })
1281
+ // Dispatches: /api/mpesa/stk-push, /api/mpesa/stk-query,
1282
+ // /api/mpesa/callback, /api/mpesa/balance
1283
+ ```
1284
+
1285
+ ---
1286
+
1287
+ ### Fastify Adapter
1288
+
1289
+ ```typescript
1290
+ import Fastify from 'fastify'
1291
+ import { registerMpesaRoutes } from 'pesafy/adapters/fastify'
1292
+
1293
+ const app = Fastify({ logger: true })
1294
+
1295
+ await registerMpesaRoutes(app, {
1296
+ consumerKey: process.env.MPESA_CONSUMER_KEY!,
1297
+ consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
1298
+ environment: 'sandbox',
1299
+ lipaNaMpesaShortCode: '174379',
1300
+ lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
1301
+ callbackUrl: 'https://yourdomain.com/mpesa/callback',
1302
+ resultUrl: 'https://yourdomain.com/mpesa/result',
1303
+ queueTimeOutUrl: 'https://yourdomain.com/mpesa/timeout',
1304
+ skipIPCheck: true,
1305
+ onStkSuccess: async ({ receiptNumber, amount, phone }) => {
1306
+ app.log.info({ receiptNumber, amount, phone }, 'Payment received')
1307
+ },
1308
+ })
1309
+
1310
+ await app.listen({ port: 3000 })
1311
+ ```
1312
+
1313
+ **Routes mounted by `registerMpesaRoutes`:**
1314
+
1315
+ | Method | Path | Description |
1316
+ | ------ | ------------------------ | -------------------------------- |
1317
+ | POST | `/mpesa/stk-push` | Initiate STK Push |
1318
+ | POST | `/mpesa/stk-query` | Query STK Push status |
1319
+ | POST | `/mpesa/callback` | STK Push callback from Safaricom |
1320
+ | POST | `/mpesa/balance` | Account balance query |
1321
+ | POST | `/mpesa/balance/result` | Account balance result |
1322
+ | POST | `/mpesa/reversal` | Reversal request |
1323
+ | POST | `/mpesa/reversal/result` | Reversal result callback |
1324
+
1325
+ ---
1326
+
1327
+ ## Branded Types
1328
+
1329
+ pesafy ships opt-in branded primitives that catch type bugs at compile time.
1330
+
1331
+ ```typescript
1332
+ import {
1333
+ toKesAmount,
1334
+ toMsisdn,
1335
+ toPaybill,
1336
+ toTill,
1337
+ toShortCode,
1338
+ toNonEmpty,
1339
+ type KesAmount,
1340
+ type MsisdnKE,
1341
+ type PaybillCode,
1342
+ type TillCode,
1343
+ } from 'pesafy'
1344
+
1345
+ const amount: KesAmount = toKesAmount(100) // throws if < 1 or fractional
1346
+ const phone: MsisdnKE = toMsisdn('0712345678') // throws if unparseable
1347
+ const code: PaybillCode = toPaybill('174379')
1348
+ const till: TillCode = toTill('5555')
1349
+ ```
1350
+
1351
+ **Result type** — prefer this over try/catch in application code:
1352
+
1353
+ ```typescript
1354
+ import { ok, err, type Result } from 'pesafy'
1355
+
1356
+ const result = await mpesa.stkPushSafe({ ... })
1357
+
1358
+ if (result.ok) {
1359
+ console.log(result.data.CheckoutRequestID)
1360
+ } else {
1361
+ // result.error is PesafyError with .code, .statusCode, .retryable
1362
+ if (result.error.retryable) {
1363
+ // schedule retry
1364
+ }
1365
+ }
1366
+ ```
1367
+
1368
+ ---
1369
+
1370
+ ## Error Handling
1371
+
1372
+ All errors are `PesafyError` instances with structured codes:
1373
+
1374
+ ```typescript
1375
+ import { PesafyError, isPesafyError } from 'pesafy'
1376
+
1377
+ try {
1378
+ await mpesa.stkPush({ ... })
1379
+ } catch (error) {
1380
+ if (isPesafyError(error)) {
1381
+ console.log(error.code) // structured error code (see table below)
1382
+ console.log(error.message)
1383
+ console.log(error.statusCode) // HTTP status from Daraja (if applicable)
1384
+ console.log(error.retryable) // boolean — safe to retry?
1385
+ console.log(error.requestId) // Daraja requestId (if returned)
1386
+ console.log(error.response) // raw Daraja response body
1387
+
1388
+ // Convenience properties
1389
+ error.isValidation // true if VALIDATION_ERROR
1390
+ error.isAuth // true if AUTH_FAILED / INVALID_CREDENTIALS
1391
+ }
1392
+ }
1393
+ ```
1394
+
1395
+ **Error codes:**
1396
+
1397
+ | Code | Retryable | Meaning |
1398
+ | --------------------- | --------- | ------------------------------------------- |
1399
+ | `AUTH_FAILED` | ❌ | OAuth token fetch failed |
1400
+ | `INVALID_CREDENTIALS` | ❌ | Missing or wrong consumerKey / Secret |
1401
+ | `INVALID_PHONE` | ❌ | Phone number cannot be normalised |
1402
+ | `ENCRYPTION_FAILED` | ❌ | RSA encryption of initiator password failed |
1403
+ | `VALIDATION_ERROR` | ❌ | Invalid request parameters |
1404
+ | `API_ERROR` | ❌ | Daraja returned a 4xx error |
1405
+ | `REQUEST_FAILED` | ✅ | Daraja returned 5xx |
1406
+ | `NETWORK_ERROR` | ✅ | DNS / connection failure |
1407
+ | `TIMEOUT` | ✅ | Request exceeded timeout |
1408
+ | `RATE_LIMITED` | ✅ | 429 Too Many Requests |
1409
+
1410
+ ---
1411
+
1412
+ ## Utilities
1413
+
1414
+ ### Phone number formatting
1415
+
1416
+ ```typescript
1417
+ import { formatSafaricomPhone } from 'pesafy'
1418
+
1419
+ formatSafaricomPhone('0712345678') // → "254712345678"
1420
+ formatSafaricomPhone('+254712345678') // → "254712345678"
1421
+ formatSafaricomPhone('712345678') // → "254712345678"
1422
+ formatSafaricomPhone('254712345678') // → "254712345678"
1423
+ ```
1424
+
1425
+ ### Security credential encryption
1426
+
1427
+ Encrypt once at startup and pass as `securityCredential` to skip per-call RSA:
1428
+
1429
+ ```typescript
1430
+ import { encryptSecurityCredential } from 'pesafy'
1431
+ import { readFileSync } from 'node:fs'
1432
+
1433
+ const pem = readFileSync('./SandboxCertificate.cer', 'utf-8')
1434
+ const credential = encryptSecurityCredential('Safaricom123!', pem)
1435
+
1436
+ const mpesa = new Mpesa({
1437
+ ...
1438
+ securityCredential: credential, // skips RSA encryption on every API call
1439
+ })
1440
+ ```
1441
+
1442
+ ### Token management
1443
+
1444
+ ```typescript
1445
+ // Force token refresh on the next call (e.g. after an unexpected 401)
1446
+ mpesa.clearTokenCache()
1447
+
1448
+ // Read the environment
1449
+ console.log(mpesa.environment) // "sandbox" | "production"
1450
+ ```
1451
+
1452
+ ---
1453
+
1454
+ ## Configuration Reference
1455
+
1456
+ | Option | Type | Required for | Default | Description |
1457
+ | ---------------------- | --------------------------- | ------------------------------ | ------- | ------------------------------------ |
1458
+ | `consumerKey` | `string` | All APIs ✅ | — | Daraja consumer key |
1459
+ | `consumerSecret` | `string` | All APIs ✅ | — | Daraja consumer secret |
1460
+ | `environment` | `"sandbox" \| "production"` | All APIs ✅ | — | Target environment |
1461
+ | `lipaNaMpesaShortCode` | `string` | STK Push | — | Paybill / HO shortcode |
1462
+ | `lipaNaMpesaPassKey` | `string` | STK Push | — | LNM passkey |
1463
+ | `initiatorName` | `string` | B2C / B2B / Reversal / Balance | — | API operator username |
1464
+ | `initiatorPassword` | `string` | B2C / B2B / Reversal / Balance | — | API operator password |
1465
+ | `certificatePath` | `string` | B2C / B2B / Reversal / Balance | — | Path to `.cer` file on disk |
1466
+ | `certificatePem` | `string` | — | — | PEM string (alternative to path) |
1467
+ | `securityCredential` | `string` | — | — | Pre-encrypted credential (skips RSA) |
1468
+ | `retries` | `number` | — | `4` | Retry count on transient errors |
1469
+ | `retryDelay` | `number` | — | `2000` | Base retry delay in ms |
1470
+ | `timeout` | `number` | — | `30000` | Per-attempt timeout in ms |
1471
+
1472
+ ---
1473
+
1474
+ ## Roadmap
1475
+
1476
+ ### Planned (Safaricom APIs)
1477
+
1478
+ - [ ] **Standing Orders** — create recurring M-PESA payment instructions
1479
+ - [ ] **M-PESA Global** — international money transfers
1480
+ - [ ] **Ratiba** — recurring bill payments
1481
+ - [ ] **Merchant QR** — static QR code generation
1482
+
1483
+ ### Planned (Library)
1484
+
1485
+ - [x] **Idempotency keys** — automatic deduplication headers
1486
+ - [x] **Webhook signature verification** — opt-in HMAC preview (IP verify
1487
+ remains default)
1488
+ - [ ] **React hooks** — `useStkPush()`, `usePaymentStatus()` with polling
1489
+ - [ ] **Vue composables** — `useStkPush()` for Vue 3
1490
+ - [ ] **OpenAPI spec** — auto-generated from types
1491
+ - [ ] **Mock server** — offline Daraja sandbox for unit testing
1492
+ - [x] **Zod schemas** — runtime validation (STK, B2C Disbursement, B2B Express +
1493
+ exported schemas)
1494
+ - [ ] **SvelteKit adapter** — `createMpesaSvelteHandler()`
1495
+ - [ ] **Astro adapter** — API route helpers
1496
+
1497
+ ---
1498
+
1499
+ ## Contributing
1500
+
1501
+ 1. Fork the repository
1502
+ 2. Create a feature branch: `git checkout -b feat/my-feature`
1503
+ 3. Commit your changes: `git commit -m '✨ Add my feature'`
1504
+ 4. Push: `git push origin feat/my-feature`
1505
+ 5. Open a Pull Request
1506
+
1507
+ ---
1508
+
1509
+ ## License
1510
+
1511
+ MIT © [Lewis Odero](https://github.com/levos-snr)
1512
+
1513
+ ---
1514
+
1515
+ ## Support
1516
+
1517
+ - 🐛 [GitHub Issues](https://github.com/levos-snr/pesafy/issues)
1518
+ - 📧 [lewisodero27@gmail.com](mailto:lewisodero27@gmail.com)
1519
+ - 📖 [Daraja Docs](https://developer.safaricom.co.ke)