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