pesafy 0.5.2 → 0.5.4

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 CHANGED
@@ -2,12 +2,15 @@
2
2
 
3
3
  # pesafy 💳
4
4
 
5
- > **Type-safe M-PESA Daraja SDK** for Node.js, Bun, Deno, Cloudflare Workers, Next.js, Fastify, Hono, and Express.
5
+ > **Type-safe M-PESA Daraja SDK** for Node.js, Bun, Deno, Cloudflare Workers,
6
+ > Next.js, Fastify, Hono, and Express.
6
7
 
7
8
  [![npm version](https://img.shields.io/npm/v/pesafy.svg)](https://www.npmjs.com/package/pesafy)
8
9
  [![npm downloads](https://img.shields.io/npm/dm/pesafy.svg)](https://www.npmjs.com/package/pesafy)
9
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
11
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue.svg)](https://www.typescriptlang.org/)
12
+ [![CI](https://github.com/levos-snr/pesafy/actions/workflows/ci.yml/badge.svg)](https://github.com/levos-snr/pesafy/actions/workflows/ci.yml)
13
+ [![codecov](https://codecov.io/github/levos-snr/pesafy/graph/badge.svg?token=JYK2BS1ZZF)](https://codecov.io/github/levos-snr/pesafy)
11
14
 
12
15
  ---
13
16
 
@@ -55,33 +58,34 @@ bun add pesafy # bun
55
58
  ## Quick Start
56
59
 
57
60
  ```typescript
58
- import { Mpesa } from "pesafy";
61
+ import { Mpesa } from 'pesafy'
59
62
 
60
63
  const mpesa = new Mpesa({
61
64
  consumerKey: process.env.MPESA_CONSUMER_KEY!,
62
65
  consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
63
- environment: "sandbox", // "sandbox" | "production"
64
- lipaNaMpesaShortCode: "174379",
66
+ environment: 'sandbox', // "sandbox" | "production"
67
+ lipaNaMpesaShortCode: '174379',
65
68
  lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
66
- });
69
+ })
67
70
 
68
71
  // Send an STK Push
69
72
  const response = await mpesa.stkPush({
70
73
  amount: 100,
71
- phoneNumber: "0712345678",
72
- callbackUrl: "https://yourdomain.com/api/mpesa/callback",
73
- accountReference: "INV-001",
74
- transactionDesc: "Payment",
75
- });
74
+ phoneNumber: '0712345678',
75
+ callbackUrl: 'https://yourdomain.com/api/mpesa/callback',
76
+ accountReference: 'INV-001',
77
+ transactionDesc: 'Payment',
78
+ })
76
79
 
77
- console.log(response.CheckoutRequestID);
80
+ console.log(response.CheckoutRequestID)
78
81
  ```
79
82
 
80
83
  ---
81
84
 
82
85
  ## CLI
83
86
 
84
- The pesafy CLI lets you interact with Daraja directly from your terminal — great for testing, debugging, and scripting.
87
+ The pesafy CLI lets you interact with Daraja directly from your terminal — great
88
+ for testing, debugging, and scripting.
85
89
 
86
90
  ### Setup
87
91
 
@@ -135,27 +139,27 @@ MPESA_QUEUE_TIMEOUT_URL
135
139
  ### Instantiating the client
136
140
 
137
141
  ```typescript
138
- import { Mpesa } from "pesafy";
142
+ import { Mpesa } from 'pesafy'
139
143
 
140
144
  const mpesa = new Mpesa({
141
- consumerKey: "...",
142
- consumerSecret: "...",
143
- environment: "sandbox",
145
+ consumerKey: '...',
146
+ consumerSecret: '...',
147
+ environment: 'sandbox',
144
148
 
145
149
  // STK Push
146
- lipaNaMpesaShortCode: "174379",
147
- lipaNaMpesaPassKey: "bfb279...",
150
+ lipaNaMpesaShortCode: '174379',
151
+ lipaNaMpesaPassKey: 'bfb279...',
148
152
 
149
153
  // Initiator-based APIs (B2C, Reversal, Balance, Tax)
150
- initiatorName: "testapi",
151
- initiatorPassword: "Safaricom123!",
152
- certificatePath: "./SandboxCertificate.cer",
154
+ initiatorName: 'testapi',
155
+ initiatorPassword: 'Safaricom123!',
156
+ certificatePath: './SandboxCertificate.cer',
153
157
 
154
158
  // HTTP tuning (optional)
155
159
  retries: 4, // default: 4
156
160
  retryDelay: 2000, // default: 2000 ms
157
161
  timeout: 30000, // default: 30 000 ms (per attempt)
158
- });
162
+ })
159
163
  ```
160
164
 
161
165
  ---
@@ -199,13 +203,17 @@ if (result.ok) {
199
203
  **Callback payload helpers:**
200
204
 
201
205
  ```typescript
202
- import { isStkCallbackSuccess, getCallbackValue, type StkPushCallback } from "pesafy";
206
+ import {
207
+ isStkCallbackSuccess,
208
+ getCallbackValue,
209
+ type StkPushCallback,
210
+ } from 'pesafy'
203
211
 
204
212
  function handleCallback(body: StkPushCallback) {
205
213
  if (isStkCallbackSuccess(body.Body.stkCallback)) {
206
- const receipt = getCallbackValue(body, "MpesaReceiptNumber"); // string
207
- const amount = getCallbackValue(body, "Amount"); // number
208
- const phone = getCallbackValue(body, "PhoneNumber"); // number
214
+ const receipt = getCallbackValue(body, 'MpesaReceiptNumber') // string
215
+ const amount = getCallbackValue(body, 'Amount') // number
216
+ const phone = getCallbackValue(body, 'PhoneNumber') // number
209
217
  }
210
218
  }
211
219
  ```
@@ -229,22 +237,22 @@ Register your Paybill or Till to receive M-PESA payments.
229
237
  ```typescript
230
238
  // 1. Register Confirmation + Validation URLs (do this once per shortcode)
231
239
  await mpesa.registerC2BUrls({
232
- shortCode: "600984",
233
- responseType: "Completed", // "Completed" | "Cancelled"
234
- confirmationUrl: "https://yourdomain.com/api/mpesa/c2b/confirmation",
235
- validationUrl: "https://yourdomain.com/api/mpesa/c2b/validation",
236
- apiVersion: "v2", // default — v2 masks MSISDN in callbacks
237
- });
240
+ shortCode: '600984',
241
+ responseType: 'Completed', // "Completed" | "Cancelled"
242
+ confirmationUrl: 'https://yourdomain.com/api/mpesa/c2b/confirmation',
243
+ validationUrl: 'https://yourdomain.com/api/mpesa/c2b/validation',
244
+ apiVersion: 'v2', // default — v2 masks MSISDN in callbacks
245
+ })
238
246
 
239
247
  // 2. Simulate (SANDBOX ONLY)
240
248
  await mpesa.simulateC2B({
241
- shortCode: "600984",
242
- commandId: "CustomerPayBillOnline", // or "CustomerBuyGoodsOnline"
249
+ shortCode: '600984',
250
+ commandId: 'CustomerPayBillOnline', // or "CustomerBuyGoodsOnline"
243
251
  amount: 10,
244
252
  msisdn: 254708374149,
245
- billRefNumber: "INV-001", // Paybill only — OMIT for Buy Goods
246
- apiVersion: "v2",
247
- });
253
+ billRefNumber: 'INV-001', // Paybill only — OMIT for Buy Goods
254
+ apiVersion: 'v2',
255
+ })
248
256
  ```
249
257
 
250
258
  **Validation webhook handlers:**
@@ -259,34 +267,34 @@ import {
259
267
  getC2BCustomerName,
260
268
  type C2BValidationPayload,
261
269
  type C2BConfirmationPayload,
262
- } from "pesafy";
270
+ } from 'pesafy'
263
271
 
264
272
  // Validation URL — must respond in ≤8 seconds
265
- app.post("/api/mpesa/c2b/validation", (req, res) => {
266
- const payload = req.body as C2BValidationPayload;
267
- const amount = getC2BAmount(payload);
273
+ app.post('/api/mpesa/c2b/validation', (req, res) => {
274
+ const payload = req.body as C2BValidationPayload
275
+ const amount = getC2BAmount(payload)
268
276
 
269
277
  if (amount > 100_000) {
270
278
  // Reject with specific error code
271
- return res.json(rejectC2BValidation("C2B00013")); // Invalid Amount
279
+ return res.json(rejectC2BValidation('C2B00013')) // Invalid Amount
272
280
  }
273
281
 
274
- res.json(acceptC2BValidation()); // Accept
282
+ res.json(acceptC2BValidation()) // Accept
275
283
  // or: res.json(acceptC2BValidation("MY-TX-ID")); // with correlation ID
276
- });
284
+ })
277
285
 
278
286
  // Confirmation URL — always respond 200 immediately
279
- app.post("/api/mpesa/c2b/confirmation", (req, res) => {
280
- const payload = req.body as C2BConfirmationPayload;
281
- const txId = getC2BTransactionId(payload);
282
- const amount = getC2BAmount(payload);
283
- const name = getC2BCustomerName(payload);
287
+ app.post('/api/mpesa/c2b/confirmation', (req, res) => {
288
+ const payload = req.body as C2BConfirmationPayload
289
+ const txId = getC2BTransactionId(payload)
290
+ const amount = getC2BAmount(payload)
291
+ const name = getC2BCustomerName(payload)
284
292
 
285
293
  // Process async
286
- processPayment({ txId, amount, name }).catch(console.error);
294
+ processPayment({ txId, amount, name }).catch(console.error)
287
295
 
288
- res.json({ ResultCode: 0, ResultDesc: "Success" });
289
- });
296
+ res.json({ ResultCode: 0, ResultDesc: 'Success' })
297
+ })
290
298
  ```
291
299
 
292
300
  **C2B Validation ResultCodes:**
@@ -310,27 +318,27 @@ Send money to customers or load funds to a B2C shortcode.
310
318
  ```typescript
311
319
  // BusinessPayToBulk — load funds to a B2C shortcode
312
320
  const ack = await mpesa.b2cPayment({
313
- commandId: "BusinessPayToBulk",
321
+ commandId: 'BusinessPayToBulk',
314
322
  amount: 50_000,
315
- partyA: "600979", // your MMF shortcode
316
- partyB: "600000", // target B2C shortcode
317
- accountReference: "BATCH-2024-01",
318
- resultUrl: "https://yourdomain.com/api/mpesa/b2c/result",
319
- queueTimeOutUrl: "https://yourdomain.com/api/mpesa/b2c/timeout",
320
- });
323
+ partyA: '600979', // your MMF shortcode
324
+ partyB: '600000', // target B2C shortcode
325
+ accountReference: 'BATCH-2024-01',
326
+ resultUrl: 'https://yourdomain.com/api/mpesa/b2c/result',
327
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/b2c/timeout',
328
+ })
321
329
 
322
330
  // BusinessPayment — direct payment to customer wallet
323
331
  await mpesa.b2cPayment({
324
- commandId: "BusinessPayment", // or "SalaryPayment" / "PromotionPayment"
332
+ commandId: 'BusinessPayment', // or "SalaryPayment" / "PromotionPayment"
325
333
  amount: 2500,
326
- partyA: "600979",
327
- partyB: "254712345678", // customer MSISDN
328
- accountReference: "SALARY-JAN",
329
- resultUrl: "https://yourdomain.com/api/mpesa/b2c/result",
330
- queueTimeOutUrl: "https://yourdomain.com/api/mpesa/b2c/timeout",
331
- remarks: "January salary",
332
- requester: "254712345678", // optional — consumer MSISDN
333
- });
334
+ partyA: '600979',
335
+ partyB: '254712345678', // customer MSISDN
336
+ accountReference: 'SALARY-JAN',
337
+ resultUrl: 'https://yourdomain.com/api/mpesa/b2c/result',
338
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/b2c/timeout',
339
+ remarks: 'January salary',
340
+ requester: '254712345678', // optional — consumer MSISDN
341
+ })
334
342
  ```
335
343
 
336
344
  **B2C Result webhook handler:**
@@ -346,22 +354,22 @@ import {
346
354
  getB2COriginatorConversationId,
347
355
  getB2CReceiverPublicName,
348
356
  getB2CDebitAccountBalance,
349
- } from "pesafy";
357
+ } from 'pesafy'
350
358
 
351
- app.post("/api/mpesa/b2c/result", (req, res) => {
352
- res.json({ ResultCode: 0, ResultDesc: "Accepted" }); // always respond 200 first
359
+ app.post('/api/mpesa/b2c/result', (req, res) => {
360
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' }) // always respond 200 first
353
361
 
354
- if (!isB2CResult(req.body)) return;
362
+ if (!isB2CResult(req.body)) return
355
363
 
356
364
  if (isB2CSuccess(req.body)) {
357
- const txId = getB2CTransactionId(req.body);
358
- const amount = getB2CAmount(req.body);
359
- const balance = getB2CDebitAccountBalance(req.body);
360
- console.log("B2C success:", { txId, amount, balance });
365
+ const txId = getB2CTransactionId(req.body)
366
+ const amount = getB2CAmount(req.body)
367
+ const balance = getB2CDebitAccountBalance(req.body)
368
+ console.log('B2C success:', { txId, amount, balance })
361
369
  } else if (isB2CFailure(req.body)) {
362
- console.error("B2C failed:", req.body.Result.ResultDesc);
370
+ console.error('B2C failed:', req.body.Result.ResultDesc)
363
371
  }
364
- });
372
+ })
365
373
  ```
366
374
 
367
375
  **B2C CommandIDs:**
@@ -381,14 +389,14 @@ Send a USSD Push to a merchant's till for B2B payments.
381
389
 
382
390
  ```typescript
383
391
  const ack = await mpesa.b2bExpressCheckout({
384
- primaryShortCode: "000001", // merchant till (debit party)
385
- receiverShortCode: "000002", // your Paybill (credit party)
392
+ primaryShortCode: '000001', // merchant till (debit party)
393
+ receiverShortCode: '000002', // your Paybill (credit party)
386
394
  amount: 5000,
387
- paymentRef: "INV-001",
388
- callbackUrl: "https://yourdomain.com/api/mpesa/b2b/callback",
389
- partnerName: "Acme Supplies",
390
- requestRefId: "unique-uuid-per-request", // auto-generated if omitted
391
- });
395
+ paymentRef: 'INV-001',
396
+ callbackUrl: 'https://yourdomain.com/api/mpesa/b2b/callback',
397
+ partnerName: 'Acme Supplies',
398
+ requestRefId: 'unique-uuid-per-request', // auto-generated if omitted
399
+ })
392
400
 
393
401
  // ack.code === "0" means USSD was initiated
394
402
  ```
@@ -403,21 +411,21 @@ import {
403
411
  getB2BTransactionId,
404
412
  getB2BAmount,
405
413
  getB2BConversationId,
406
- } from "pesafy";
414
+ } from 'pesafy'
407
415
 
408
- app.post("/api/mpesa/b2b/callback", (req, res) => {
409
- res.json({ ResultCode: 0, ResultDesc: "Accepted" });
416
+ app.post('/api/mpesa/b2b/callback', (req, res) => {
417
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
410
418
 
411
- if (!isB2BCheckoutCallback(req.body)) return;
419
+ if (!isB2BCheckoutCallback(req.body)) return
412
420
 
413
421
  if (isB2BCheckoutSuccess(req.body)) {
414
- const txId = getB2BTransactionId(req.body);
415
- const amount = getB2BAmount(req.body);
416
- console.log("B2B paid:", { txId, amount });
422
+ const txId = getB2BTransactionId(req.body)
423
+ const amount = getB2BAmount(req.body)
424
+ console.log('B2B paid:', { txId, amount })
417
425
  } else if (isB2BCheckoutCancelled(req.body)) {
418
- console.log("B2B cancelled by merchant");
426
+ console.log('B2B cancelled by merchant')
419
427
  }
420
- });
428
+ })
421
429
  ```
422
430
 
423
431
  **B2B Error codes:**
@@ -435,16 +443,17 @@ app.post("/api/mpesa/b2b/callback", (req, res) => {
435
443
 
436
444
  ### Account Balance
437
445
 
438
- Query the balance of your M-PESA shortcode. **Asynchronous** — result is POSTed to your `resultUrl`.
446
+ Query the balance of your M-PESA shortcode. **Asynchronous** — result is POSTed
447
+ to your `resultUrl`.
439
448
 
440
449
  ```typescript
441
450
  await mpesa.accountBalance({
442
- partyA: "174379",
443
- identifierType: "4", // "1"=MSISDN, "2"=Till, "4"=ShortCode
444
- resultUrl: "https://yourdomain.com/api/mpesa/balance/result",
445
- queueTimeOutUrl: "https://yourdomain.com/api/mpesa/balance/timeout",
446
- remarks: "Balance check",
447
- });
451
+ partyA: '174379',
452
+ identifierType: '4', // "1"=MSISDN, "2"=Till, "4"=ShortCode
453
+ resultUrl: 'https://yourdomain.com/api/mpesa/balance/result',
454
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/balance/timeout',
455
+ remarks: 'Balance check',
456
+ })
448
457
  ```
449
458
 
450
459
  **Parsing the result:**
@@ -455,22 +464,22 @@ import {
455
464
  parseAccountBalance,
456
465
  getAccountBalanceParam,
457
466
  type AccountBalanceResult,
458
- } from "pesafy";
467
+ } from 'pesafy'
459
468
 
460
- app.post("/api/mpesa/balance/result", (req, res) => {
461
- res.json({ ResultCode: 0, ResultDesc: "Accepted" });
469
+ app.post('/api/mpesa/balance/result', (req, res) => {
470
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
462
471
 
463
- const body = req.body as AccountBalanceResult;
464
- if (!isAccountBalanceSuccess(body)) return;
472
+ const body = req.body as AccountBalanceResult
473
+ if (!isAccountBalanceSuccess(body)) return
465
474
 
466
- const raw = getAccountBalanceParam(body, "AccountBalance") as string;
467
- const accounts = parseAccountBalance(raw ?? "");
475
+ const raw = getAccountBalanceParam(body, 'AccountBalance') as string
476
+ const accounts = parseAccountBalance(raw ?? '')
468
477
 
469
478
  for (const account of accounts) {
470
- console.log(`${account.name}: ${account.currency} ${account.amount}`);
479
+ console.log(`${account.name}: ${account.currency} ${account.amount}`)
471
480
  // e.g. "Working Account: KES 45000.00"
472
481
  }
473
- });
482
+ })
474
483
  ```
475
484
 
476
485
  ---
@@ -481,13 +490,13 @@ Query the result of any completed M-PESA transaction. **Asynchronous**.
481
490
 
482
491
  ```typescript
483
492
  await mpesa.transactionStatus({
484
- transactionId: "OEI2AK4XXXX",
485
- partyA: "174379",
486
- identifierType: "4",
487
- resultUrl: "https://yourdomain.com/api/mpesa/tx/result",
488
- queueTimeOutUrl: "https://yourdomain.com/api/mpesa/tx/timeout",
489
- remarks: "Check payment status",
490
- });
493
+ transactionId: 'OEI2AK4XXXX',
494
+ partyA: '174379',
495
+ identifierType: '4',
496
+ resultUrl: 'https://yourdomain.com/api/mpesa/tx/result',
497
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/tx/timeout',
498
+ remarks: 'Check payment status',
499
+ })
491
500
  ```
492
501
 
493
502
  ---
@@ -498,28 +507,28 @@ Reverse a completed M-PESA transaction. **Asynchronous**.
498
507
 
499
508
  ```typescript
500
509
  await mpesa.reverseTransaction({
501
- transactionId: "OEI2AK4XXXX",
502
- receiverParty: "174379",
503
- receiverIdentifierType: "4", // "1"=MSISDN, "2"=Till, "4"=ShortCode
510
+ transactionId: 'OEI2AK4XXXX',
511
+ receiverParty: '174379',
512
+ receiverIdentifierType: '4', // "1"=MSISDN, "2"=Till, "4"=ShortCode
504
513
  amount: 500,
505
- resultUrl: "https://yourdomain.com/api/mpesa/reversal/result",
506
- queueTimeOutUrl: "https://yourdomain.com/api/mpesa/reversal/timeout",
507
- remarks: "Erroneous charge",
508
- });
514
+ resultUrl: 'https://yourdomain.com/api/mpesa/reversal/result',
515
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/reversal/timeout',
516
+ remarks: 'Erroneous charge',
517
+ })
509
518
  ```
510
519
 
511
520
  **Reversal result handler:**
512
521
 
513
522
  ```typescript
514
- import { isReversalSuccess, getReversalTransactionId } from "pesafy";
523
+ import { isReversalSuccess, getReversalTransactionId } from 'pesafy'
515
524
 
516
- app.post("/api/mpesa/reversal/result", (req, res) => {
517
- res.json({ ResultCode: 0, ResultDesc: "Accepted" });
525
+ app.post('/api/mpesa/reversal/result', (req, res) => {
526
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
518
527
 
519
528
  if (isReversalSuccess(req.body)) {
520
- console.log("Reversed:", getReversalTransactionId(req.body));
529
+ console.log('Reversed:', getReversalTransactionId(req.body))
521
530
  }
522
- });
531
+ })
523
532
  ```
524
533
 
525
534
  ---
@@ -531,12 +540,12 @@ Remit tax to Kenya Revenue Authority via M-PESA. **Asynchronous**.
531
540
  ```typescript
532
541
  await mpesa.remitTax({
533
542
  amount: 5_000,
534
- partyA: "888880", // your business shortcode
535
- accountReference: "PRN1234XN", // KRA Payment Registration Number
536
- resultUrl: "https://yourdomain.com/api/mpesa/tax/result",
537
- queueTimeOutUrl: "https://yourdomain.com/api/mpesa/tax/timeout",
538
- remarks: "Monthly PAYE",
539
- });
543
+ partyA: '888880', // your business shortcode
544
+ accountReference: 'PRN1234XN', // KRA Payment Registration Number
545
+ resultUrl: 'https://yourdomain.com/api/mpesa/tax/result',
546
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/tax/timeout',
547
+ remarks: 'Monthly PAYE',
548
+ })
540
549
  // PartyB is always KRA_SHORTCODE ("572572") — auto-set
541
550
  ```
542
551
 
@@ -548,13 +557,13 @@ Generate an M-PESA QR code customers can scan to pay.
548
557
 
549
558
  ```typescript
550
559
  const qr = await mpesa.generateDynamicQR({
551
- merchantName: "My Shop",
552
- refNo: "INV-001",
560
+ merchantName: 'My Shop',
561
+ refNo: 'INV-001',
553
562
  amount: 500,
554
- trxCode: "BG", // "BG"=Buy Goods, "PB"=Paybill, "WA"=Withdraw, "SM"=Send Money
555
- cpi: "373132", // till / paybill / MSISDN
563
+ trxCode: 'BG', // "BG"=Buy Goods, "PB"=Paybill, "WA"=Withdraw, "SM"=Send Money
564
+ cpi: '373132', // till / paybill / MSISDN
556
565
  size: 300, // pixels (square)
557
- });
566
+ })
558
567
 
559
568
  // Render in HTML:
560
569
  // <img src={`data:image/png;base64,${qr.QRCode}`} />
@@ -621,52 +630,57 @@ await mpesa.cancelInvoice({ externalReference: "INV-001" });
621
630
  **IP verification** (Safaricom always calls from whitelisted IPs):
622
631
 
623
632
  ```typescript
624
- import { verifyWebhookIP, SAFARICOM_IPS } from "pesafy";
633
+ import { verifyWebhookIP, SAFARICOM_IPS } from 'pesafy'
625
634
 
626
- app.post("/api/mpesa/callback", (req, res) => {
627
- const ip = req.ip ?? req.headers["x-forwarded-for"];
635
+ app.post('/api/mpesa/callback', (req, res) => {
636
+ const ip = req.ip ?? req.headers['x-forwarded-for']
628
637
  if (!verifyWebhookIP(ip)) {
629
- console.warn("Callback from unknown IP:", ip);
638
+ console.warn('Callback from unknown IP:', ip)
630
639
  // Still return 200 — Safaricom will retry if you reject
631
640
  }
632
641
  // ... process
633
- res.json({ ResultCode: 0, ResultDesc: "Accepted" });
634
- });
642
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
643
+ })
635
644
  ```
636
645
 
637
646
  **Generic webhook handler:**
638
647
 
639
648
  ```typescript
640
- import { handleWebhook, isSuccessfulCallback, extractTransactionId, extractAmount } from "pesafy";
649
+ import {
650
+ handleWebhook,
651
+ isSuccessfulCallback,
652
+ extractTransactionId,
653
+ extractAmount,
654
+ } from 'pesafy'
641
655
 
642
- app.post("/api/mpesa/callback", (req, res) => {
656
+ app.post('/api/mpesa/callback', (req, res) => {
643
657
  const result = handleWebhook(req.body, {
644
658
  requestIP: req.ip,
645
659
  skipIPCheck: false, // set true in local dev
646
- });
660
+ })
647
661
 
648
662
  if (result.success && isSuccessfulCallback(result.data)) {
649
- const receipt = extractTransactionId(result.data);
650
- const amount = extractAmount(result.data);
663
+ const receipt = extractTransactionId(result.data)
664
+ const amount = extractAmount(result.data)
651
665
  }
652
666
 
653
- res.json({ ResultCode: 0, ResultDesc: "Accepted" });
654
- });
667
+ res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
668
+ })
655
669
  ```
656
670
 
657
671
  **Retry with backoff** (for your own internal processing):
658
672
 
659
673
  ```typescript
660
- import { retryWithBackoff } from "pesafy";
674
+ import { retryWithBackoff } from 'pesafy'
661
675
 
662
676
  const outcome = await retryWithBackoff(() => saveToDatabase(webhookData), {
663
677
  maxRetries: 5,
664
678
  initialDelay: 500,
665
679
  maxDelay: 30_000,
666
- });
680
+ })
667
681
 
668
682
  if (!outcome.success) {
669
- console.error("Failed after", outcome.attempts, "attempts");
683
+ console.error('Failed after', outcome.attempts, 'attempts')
670
684
  }
671
685
  ```
672
686
 
@@ -677,64 +691,70 @@ if (!outcome.success) {
677
691
  ### Express Adapter
678
692
 
679
693
  ```typescript
680
- import express from "express";
694
+ import express from 'express'
681
695
  import {
682
696
  createMpesaExpressRouter,
683
697
  acceptC2BValidation,
684
698
  isB2CSuccess,
685
699
  getB2CTransactionId,
686
- } from "pesafy/express";
700
+ } from 'pesafy/express'
687
701
 
688
- const app = express();
689
- app.use(express.json());
702
+ const app = express()
703
+ app.use(express.json())
690
704
 
691
- const router = express.Router();
705
+ const router = express.Router()
692
706
 
693
707
  createMpesaExpressRouter(router, {
694
708
  consumerKey: process.env.MPESA_CONSUMER_KEY!,
695
709
  consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
696
- environment: "sandbox",
697
- lipaNaMpesaShortCode: "174379",
710
+ environment: 'sandbox',
711
+ lipaNaMpesaShortCode: '174379',
698
712
  lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
699
- callbackUrl: "https://yourdomain.com/api/mpesa/express/callback",
713
+ callbackUrl: 'https://yourdomain.com/api/mpesa/express/callback',
700
714
 
701
715
  // Initiator (for B2C, Reversal, Balance)
702
- initiatorName: "testapi",
703
- initiatorPassword: "Safaricom123!",
704
- certificatePath: "./SandboxCertificate.cer",
716
+ initiatorName: 'testapi',
717
+ initiatorPassword: 'Safaricom123!',
718
+ certificatePath: './SandboxCertificate.cer',
705
719
 
706
720
  // Result endpoints
707
- resultUrl: "https://yourdomain.com/api/mpesa/transaction-status/result",
708
- queueTimeOutUrl: "https://yourdomain.com/api/mpesa/timeout",
721
+ resultUrl: 'https://yourdomain.com/api/mpesa/transaction-status/result',
722
+ queueTimeOutUrl: 'https://yourdomain.com/api/mpesa/timeout',
709
723
 
710
724
  // C2B
711
- c2bShortCode: "600984",
712
- c2bConfirmationUrl: "https://yourdomain.com/api/mpesa/c2b/confirmation",
713
- c2bValidationUrl: "https://yourdomain.com/api/mpesa/c2b/validation",
725
+ c2bShortCode: '600984',
726
+ c2bConfirmationUrl: 'https://yourdomain.com/api/mpesa/c2b/confirmation',
727
+ c2bValidationUrl: 'https://yourdomain.com/api/mpesa/c2b/validation',
714
728
  onC2BValidation: async (payload) => {
715
- const amount = Number(payload.TransAmount);
716
- if (amount > 100_000) return { ResultCode: "C2B00013", ResultDesc: "Rejected" };
717
- return acceptC2BValidation();
729
+ const amount = Number(payload.TransAmount)
730
+ if (amount > 100_000)
731
+ return { ResultCode: 'C2B00013', ResultDesc: 'Rejected' }
732
+ return acceptC2BValidation()
718
733
  },
719
734
  onC2BConfirmation: async (payload) => {
720
- await db.payments.create({ txId: payload.TransID, amount: Number(payload.TransAmount) });
735
+ await db.payments.create({
736
+ txId: payload.TransID,
737
+ amount: Number(payload.TransAmount),
738
+ })
721
739
  },
722
740
 
723
741
  // B2C
724
- b2cPartyA: "600979",
725
- b2cResultUrl: "https://yourdomain.com/api/mpesa/b2c/result",
726
- b2cQueueTimeOutUrl: "https://yourdomain.com/api/mpesa/b2c/timeout",
742
+ b2cPartyA: '600979',
743
+ b2cResultUrl: 'https://yourdomain.com/api/mpesa/b2c/result',
744
+ b2cQueueTimeOutUrl: 'https://yourdomain.com/api/mpesa/b2c/timeout',
727
745
  onB2CResult: async (result) => {
728
746
  if (isB2CSuccess(result)) {
729
- await db.disbursements.markCompleted({ txId: getB2CTransactionId(result)! });
747
+ await db.disbursements.markCompleted({
748
+ txId: getB2CTransactionId(result)!,
749
+ })
730
750
  }
731
751
  },
732
752
 
733
753
  skipIPCheck: true, // local dev only
734
- });
754
+ })
735
755
 
736
- app.use("/api", router);
737
- app.listen(3000);
756
+ app.use('/api', router)
757
+ app.listen(3000)
738
758
  ```
739
759
 
740
760
  **Routes mounted by `createMpesaExpressRouter`:**
@@ -764,35 +784,35 @@ app.listen(3000);
764
784
  Works on Bun, Cloudflare Workers, Deno, and Node.js via Hono.
765
785
 
766
786
  ```typescript
767
- import { Hono } from "hono";
768
- import { createMpesaHonoRouter } from "pesafy/adapters/hono";
787
+ import { Hono } from 'hono'
788
+ import { createMpesaHonoRouter } from 'pesafy/adapters/hono'
769
789
 
770
- const app = new Hono();
790
+ const app = new Hono()
771
791
 
772
792
  createMpesaHonoRouter(app, {
773
793
  consumerKey: process.env.MPESA_CONSUMER_KEY!,
774
794
  consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
775
- environment: "sandbox",
776
- lipaNaMpesaShortCode: "174379",
795
+ environment: 'sandbox',
796
+ lipaNaMpesaShortCode: '174379',
777
797
  lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
778
- callbackUrl: "https://yourdomain.com/mpesa/express/callback",
779
- resultUrl: "https://yourdomain.com/mpesa/result",
780
- queueTimeOutUrl: "https://yourdomain.com/mpesa/timeout",
798
+ callbackUrl: 'https://yourdomain.com/mpesa/express/callback',
799
+ resultUrl: 'https://yourdomain.com/mpesa/result',
800
+ queueTimeOutUrl: 'https://yourdomain.com/mpesa/timeout',
781
801
 
782
802
  onStkSuccess: async ({ receiptNumber, amount, phone }) => {
783
- await db.payments.create({ receiptNumber, amount, phone });
803
+ await db.payments.create({ receiptNumber, amount, phone })
784
804
  },
785
805
  onStkFailure: ({ resultCode, resultDesc }) => {
786
- console.warn("STK failed:", resultCode, resultDesc);
806
+ console.warn('STK failed:', resultCode, resultDesc)
787
807
  },
788
808
  skipIPCheck: true,
789
- });
809
+ })
790
810
 
791
811
  // Bun
792
- export default app;
812
+ export default app
793
813
 
794
814
  // Cloudflare Workers
795
- export default { fetch: app.fetch };
815
+ export default { fetch: app.fetch }
796
816
  ```
797
817
 
798
818
  ---
@@ -801,48 +821,52 @@ export default { fetch: app.fetch };
801
821
 
802
822
  ```typescript
803
823
  // app/api/mpesa/stk-push/route.ts
804
- import { createStkPushHandler } from "pesafy/adapters/nextjs";
824
+ import { createStkPushHandler } from 'pesafy/adapters/nextjs'
805
825
 
806
826
  export const POST = createStkPushHandler({
807
827
  consumerKey: process.env.MPESA_CONSUMER_KEY!,
808
828
  consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
809
- environment: "sandbox",
829
+ environment: 'sandbox',
810
830
  lipaNaMpesaShortCode: process.env.MPESA_SHORTCODE!,
811
831
  lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
812
832
  callbackUrl: process.env.MPESA_CALLBACK_URL!,
813
- });
833
+ })
814
834
  ```
815
835
 
816
836
  ```typescript
817
837
  // app/api/mpesa/callback/route.ts
818
- import { createStkCallbackHandler } from "pesafy/adapters/nextjs";
838
+ import { createStkCallbackHandler } from 'pesafy/adapters/nextjs'
819
839
 
820
840
  export const POST = createStkCallbackHandler({
821
841
  consumerKey: process.env.MPESA_CONSUMER_KEY!,
822
842
  consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
823
- environment: "sandbox",
843
+ environment: 'sandbox',
824
844
  callbackUrl: process.env.MPESA_CALLBACK_URL!,
825
845
  onSuccess: async ({ receiptNumber, amount, phone }) => {
826
- await db.payments.create({ receiptNumber, amount: amount ?? 0, phone: phone ?? "" });
846
+ await db.payments.create({
847
+ receiptNumber,
848
+ amount: amount ?? 0,
849
+ phone: phone ?? '',
850
+ })
827
851
  },
828
852
  onFailure: ({ resultCode, resultDesc }) => {
829
- console.warn("Payment failed:", resultCode, resultDesc);
853
+ console.warn('Payment failed:', resultCode, resultDesc)
830
854
  },
831
- });
855
+ })
832
856
  ```
833
857
 
834
858
  ```typescript
835
859
  // app/api/mpesa/stk-query/route.ts
836
- import { createStkQueryHandler } from "pesafy/adapters/nextjs";
860
+ import { createStkQueryHandler } from 'pesafy/adapters/nextjs'
837
861
 
838
862
  export const POST = createStkQueryHandler({
839
863
  consumerKey: process.env.MPESA_CONSUMER_KEY!,
840
864
  consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
841
- environment: "sandbox",
865
+ environment: 'sandbox',
842
866
  lipaNaMpesaShortCode: process.env.MPESA_SHORTCODE!,
843
867
  lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
844
868
  callbackUrl: process.env.MPESA_CALLBACK_URL!,
845
- });
869
+ })
846
870
  ```
847
871
 
848
872
  ---
@@ -850,34 +874,35 @@ export const POST = createStkQueryHandler({
850
874
  ### Fastify Adapter
851
875
 
852
876
  ```typescript
853
- import Fastify from "fastify";
854
- import { registerMpesaRoutes } from "pesafy/adapters/fastify";
877
+ import Fastify from 'fastify'
878
+ import { registerMpesaRoutes } from 'pesafy/adapters/fastify'
855
879
 
856
- const app = Fastify({ logger: true });
880
+ const app = Fastify({ logger: true })
857
881
 
858
882
  await registerMpesaRoutes(app, {
859
883
  consumerKey: process.env.MPESA_CONSUMER_KEY!,
860
884
  consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
861
- environment: "sandbox",
862
- lipaNaMpesaShortCode: "174379",
885
+ environment: 'sandbox',
886
+ lipaNaMpesaShortCode: '174379',
863
887
  lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
864
- callbackUrl: "https://yourdomain.com/mpesa/callback",
865
- resultUrl: "https://yourdomain.com/mpesa/result",
866
- queueTimeOutUrl: "https://yourdomain.com/mpesa/timeout",
888
+ callbackUrl: 'https://yourdomain.com/mpesa/callback',
889
+ resultUrl: 'https://yourdomain.com/mpesa/result',
890
+ queueTimeOutUrl: 'https://yourdomain.com/mpesa/timeout',
867
891
  skipIPCheck: true,
868
892
  onStkSuccess: async ({ receiptNumber, amount, phone }) => {
869
- app.log.info({ receiptNumber, amount, phone }, "Payment received");
893
+ app.log.info({ receiptNumber, amount, phone }, 'Payment received')
870
894
  },
871
- });
895
+ })
872
896
 
873
- await app.listen({ port: 3000 });
897
+ await app.listen({ port: 3000 })
874
898
  ```
875
899
 
876
900
  ---
877
901
 
878
902
  ## Branded Types
879
903
 
880
- pesafy ships opt-in branded primitives that catch type bugs at compile time — not at runtime.
904
+ pesafy ships opt-in branded primitives that catch type bugs at compile time —
905
+ not at runtime.
881
906
 
882
907
  ```typescript
883
908
  import {
@@ -887,11 +912,11 @@ import {
887
912
  type KesAmount,
888
913
  type MsisdnKE,
889
914
  type PaybillCode,
890
- } from "pesafy";
915
+ } from 'pesafy'
891
916
 
892
- const amount: KesAmount = toKesAmount(100); // throws if < 1 or fractional
893
- const phone: MsisdnKE = toMsisdn("0712345678"); // throws if unparseable
894
- const code: PaybillCode = toPaybill("174379");
917
+ const amount: KesAmount = toKesAmount(100) // throws if < 1 or fractional
918
+ const phone: MsisdnKE = toMsisdn('0712345678') // throws if unparseable
919
+ const code: PaybillCode = toPaybill('174379')
895
920
 
896
921
  // ✅ Safe — editor shows exact types
897
922
  // ❌ Compile error — can't pass plain number where KesAmount is expected
@@ -962,22 +987,22 @@ try {
962
987
  ### Phone number formatting
963
988
 
964
989
  ```typescript
965
- import { formatSafaricomPhone } from "pesafy";
990
+ import { formatSafaricomPhone } from 'pesafy'
966
991
 
967
- formatSafaricomPhone("0712345678"); // → "254712345678"
968
- formatSafaricomPhone("+254712345678"); // → "254712345678"
969
- formatSafaricomPhone("712345678"); // → "254712345678"
970
- formatSafaricomPhone("254712345678"); // → "254712345678"
992
+ formatSafaricomPhone('0712345678') // → "254712345678"
993
+ formatSafaricomPhone('+254712345678') // → "254712345678"
994
+ formatSafaricomPhone('712345678') // → "254712345678"
995
+ formatSafaricomPhone('254712345678') // → "254712345678"
971
996
  ```
972
997
 
973
998
  ### Security credential encryption
974
999
 
975
1000
  ```typescript
976
- import { encryptSecurityCredential } from "pesafy";
977
- import { readFileSync } from "fs";
1001
+ import { encryptSecurityCredential } from 'pesafy'
1002
+ import { readFileSync } from 'fs'
978
1003
 
979
- const pem = readFileSync("./SandboxCertificate.cer", "utf-8");
980
- const credential = encryptSecurityCredential("Safaricom123!", pem);
1004
+ const pem = readFileSync('./SandboxCertificate.cer', 'utf-8')
1005
+ const credential = encryptSecurityCredential('Safaricom123!', pem)
981
1006
  // Pass as config.securityCredential to skip per-call encryption
982
1007
  ```
983
1008