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