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/CHANGELOG.md +12 -0
- package/README.md +257 -232
- package/dist/adapters/express.d.ts +494 -273
- package/dist/adapters/express.js +1 -1
- package/dist/cli.mjs +0 -21
- package/dist/index.d.ts +772 -390
- 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,15 @@
|
|
|
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)
|
|
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
|
|
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:
|
|
64
|
-
lipaNaMpesaShortCode:
|
|
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:
|
|
72
|
-
callbackUrl:
|
|
73
|
-
accountReference:
|
|
74
|
-
transactionDesc:
|
|
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
|
|
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
|
|
142
|
+
import { Mpesa } from 'pesafy'
|
|
139
143
|
|
|
140
144
|
const mpesa = new Mpesa({
|
|
141
|
-
consumerKey:
|
|
142
|
-
consumerSecret:
|
|
143
|
-
environment:
|
|
145
|
+
consumerKey: '...',
|
|
146
|
+
consumerSecret: '...',
|
|
147
|
+
environment: 'sandbox',
|
|
144
148
|
|
|
145
149
|
// STK Push
|
|
146
|
-
lipaNaMpesaShortCode:
|
|
147
|
-
lipaNaMpesaPassKey:
|
|
150
|
+
lipaNaMpesaShortCode: '174379',
|
|
151
|
+
lipaNaMpesaPassKey: 'bfb279...',
|
|
148
152
|
|
|
149
153
|
// Initiator-based APIs (B2C, Reversal, Balance, Tax)
|
|
150
|
-
initiatorName:
|
|
151
|
-
initiatorPassword:
|
|
152
|
-
certificatePath:
|
|
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 {
|
|
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,
|
|
207
|
-
const amount = getCallbackValue(body,
|
|
208
|
-
const phone = getCallbackValue(body,
|
|
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:
|
|
233
|
-
responseType:
|
|
234
|
-
confirmationUrl:
|
|
235
|
-
validationUrl:
|
|
236
|
-
apiVersion:
|
|
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:
|
|
242
|
-
commandId:
|
|
249
|
+
shortCode: '600984',
|
|
250
|
+
commandId: 'CustomerPayBillOnline', // or "CustomerBuyGoodsOnline"
|
|
243
251
|
amount: 10,
|
|
244
252
|
msisdn: 254708374149,
|
|
245
|
-
billRefNumber:
|
|
246
|
-
apiVersion:
|
|
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
|
|
270
|
+
} from 'pesafy'
|
|
263
271
|
|
|
264
272
|
// Validation URL — must respond in ≤8 seconds
|
|
265
|
-
app.post(
|
|
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(
|
|
279
|
+
return res.json(rejectC2BValidation('C2B00013')) // Invalid Amount
|
|
272
280
|
}
|
|
273
281
|
|
|
274
|
-
res.json(acceptC2BValidation())
|
|
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(
|
|
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:
|
|
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:
|
|
321
|
+
commandId: 'BusinessPayToBulk',
|
|
314
322
|
amount: 50_000,
|
|
315
|
-
partyA:
|
|
316
|
-
partyB:
|
|
317
|
-
accountReference:
|
|
318
|
-
resultUrl:
|
|
319
|
-
queueTimeOutUrl:
|
|
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:
|
|
332
|
+
commandId: 'BusinessPayment', // or "SalaryPayment" / "PromotionPayment"
|
|
325
333
|
amount: 2500,
|
|
326
|
-
partyA:
|
|
327
|
-
partyB:
|
|
328
|
-
accountReference:
|
|
329
|
-
resultUrl:
|
|
330
|
-
queueTimeOutUrl:
|
|
331
|
-
remarks:
|
|
332
|
-
requester:
|
|
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
|
|
357
|
+
} from 'pesafy'
|
|
350
358
|
|
|
351
|
-
app.post(
|
|
352
|
-
res.json({ ResultCode: 0, ResultDesc:
|
|
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(
|
|
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(
|
|
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:
|
|
385
|
-
receiverShortCode:
|
|
392
|
+
primaryShortCode: '000001', // merchant till (debit party)
|
|
393
|
+
receiverShortCode: '000002', // your Paybill (credit party)
|
|
386
394
|
amount: 5000,
|
|
387
|
-
paymentRef:
|
|
388
|
-
callbackUrl:
|
|
389
|
-
partnerName:
|
|
390
|
-
requestRefId:
|
|
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
|
|
414
|
+
} from 'pesafy'
|
|
407
415
|
|
|
408
|
-
app.post(
|
|
409
|
-
res.json({ ResultCode: 0, ResultDesc:
|
|
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(
|
|
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(
|
|
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
|
|
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:
|
|
443
|
-
identifierType:
|
|
444
|
-
resultUrl:
|
|
445
|
-
queueTimeOutUrl:
|
|
446
|
-
remarks:
|
|
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
|
|
467
|
+
} from 'pesafy'
|
|
459
468
|
|
|
460
|
-
app.post(
|
|
461
|
-
res.json({ ResultCode: 0, ResultDesc:
|
|
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,
|
|
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:
|
|
485
|
-
partyA:
|
|
486
|
-
identifierType:
|
|
487
|
-
resultUrl:
|
|
488
|
-
queueTimeOutUrl:
|
|
489
|
-
remarks:
|
|
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:
|
|
502
|
-
receiverParty:
|
|
503
|
-
receiverIdentifierType:
|
|
510
|
+
transactionId: 'OEI2AK4XXXX',
|
|
511
|
+
receiverParty: '174379',
|
|
512
|
+
receiverIdentifierType: '4', // "1"=MSISDN, "2"=Till, "4"=ShortCode
|
|
504
513
|
amount: 500,
|
|
505
|
-
resultUrl:
|
|
506
|
-
queueTimeOutUrl:
|
|
507
|
-
remarks:
|
|
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
|
|
523
|
+
import { isReversalSuccess, getReversalTransactionId } from 'pesafy'
|
|
515
524
|
|
|
516
|
-
app.post(
|
|
517
|
-
res.json({ ResultCode: 0, ResultDesc:
|
|
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(
|
|
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:
|
|
535
|
-
accountReference:
|
|
536
|
-
resultUrl:
|
|
537
|
-
queueTimeOutUrl:
|
|
538
|
-
remarks:
|
|
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:
|
|
552
|
-
refNo:
|
|
560
|
+
merchantName: 'My Shop',
|
|
561
|
+
refNo: 'INV-001',
|
|
553
562
|
amount: 500,
|
|
554
|
-
trxCode:
|
|
555
|
-
cpi:
|
|
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
|
|
633
|
+
import { verifyWebhookIP, SAFARICOM_IPS } from 'pesafy'
|
|
625
634
|
|
|
626
|
-
app.post(
|
|
627
|
-
const ip = req.ip ?? req.headers[
|
|
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(
|
|
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:
|
|
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 {
|
|
649
|
+
import {
|
|
650
|
+
handleWebhook,
|
|
651
|
+
isSuccessfulCallback,
|
|
652
|
+
extractTransactionId,
|
|
653
|
+
extractAmount,
|
|
654
|
+
} from 'pesafy'
|
|
641
655
|
|
|
642
|
-
app.post(
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
694
|
+
import express from 'express'
|
|
681
695
|
import {
|
|
682
696
|
createMpesaExpressRouter,
|
|
683
697
|
acceptC2BValidation,
|
|
684
698
|
isB2CSuccess,
|
|
685
699
|
getB2CTransactionId,
|
|
686
|
-
} from
|
|
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:
|
|
697
|
-
lipaNaMpesaShortCode:
|
|
710
|
+
environment: 'sandbox',
|
|
711
|
+
lipaNaMpesaShortCode: '174379',
|
|
698
712
|
lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
|
|
699
|
-
callbackUrl:
|
|
713
|
+
callbackUrl: 'https://yourdomain.com/api/mpesa/express/callback',
|
|
700
714
|
|
|
701
715
|
// Initiator (for B2C, Reversal, Balance)
|
|
702
|
-
initiatorName:
|
|
703
|
-
initiatorPassword:
|
|
704
|
-
certificatePath:
|
|
716
|
+
initiatorName: 'testapi',
|
|
717
|
+
initiatorPassword: 'Safaricom123!',
|
|
718
|
+
certificatePath: './SandboxCertificate.cer',
|
|
705
719
|
|
|
706
720
|
// Result endpoints
|
|
707
|
-
resultUrl:
|
|
708
|
-
queueTimeOutUrl:
|
|
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:
|
|
712
|
-
c2bConfirmationUrl:
|
|
713
|
-
c2bValidationUrl:
|
|
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)
|
|
717
|
-
|
|
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({
|
|
735
|
+
await db.payments.create({
|
|
736
|
+
txId: payload.TransID,
|
|
737
|
+
amount: Number(payload.TransAmount),
|
|
738
|
+
})
|
|
721
739
|
},
|
|
722
740
|
|
|
723
741
|
// B2C
|
|
724
|
-
b2cPartyA:
|
|
725
|
-
b2cResultUrl:
|
|
726
|
-
b2cQueueTimeOutUrl:
|
|
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({
|
|
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(
|
|
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
|
|
768
|
-
import { createMpesaHonoRouter } from
|
|
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:
|
|
776
|
-
lipaNaMpesaShortCode:
|
|
795
|
+
environment: 'sandbox',
|
|
796
|
+
lipaNaMpesaShortCode: '174379',
|
|
777
797
|
lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
|
|
778
|
-
callbackUrl:
|
|
779
|
-
resultUrl:
|
|
780
|
-
queueTimeOutUrl:
|
|
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(
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
843
|
+
environment: 'sandbox',
|
|
824
844
|
callbackUrl: process.env.MPESA_CALLBACK_URL!,
|
|
825
845
|
onSuccess: async ({ receiptNumber, amount, phone }) => {
|
|
826
|
-
await db.payments.create({
|
|
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(
|
|
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
|
|
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:
|
|
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
|
|
854
|
-
import { registerMpesaRoutes } from
|
|
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:
|
|
862
|
-
lipaNaMpesaShortCode:
|
|
885
|
+
environment: 'sandbox',
|
|
886
|
+
lipaNaMpesaShortCode: '174379',
|
|
863
887
|
lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
|
|
864
|
-
callbackUrl:
|
|
865
|
-
resultUrl:
|
|
866
|
-
queueTimeOutUrl:
|
|
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 },
|
|
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 —
|
|
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
|
|
915
|
+
} from 'pesafy'
|
|
891
916
|
|
|
892
|
-
const amount: KesAmount = toKesAmount(100)
|
|
893
|
-
const phone: MsisdnKE = toMsisdn(
|
|
894
|
-
const code: PaybillCode = toPaybill(
|
|
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
|
|
990
|
+
import { formatSafaricomPhone } from 'pesafy'
|
|
966
991
|
|
|
967
|
-
formatSafaricomPhone(
|
|
968
|
-
formatSafaricomPhone(
|
|
969
|
-
formatSafaricomPhone(
|
|
970
|
-
formatSafaricomPhone(
|
|
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
|
|
977
|
-
import { readFileSync } from
|
|
1001
|
+
import { encryptSecurityCredential } from 'pesafy'
|
|
1002
|
+
import { readFileSync } from 'fs'
|
|
978
1003
|
|
|
979
|
-
const pem = readFileSync(
|
|
980
|
-
const credential = encryptSecurityCredential(
|
|
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
|
|