pesafy 0.4.2 β 0.5.0
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 +171 -0
- package/README.md +981 -108
- package/dist/adapters/fastify.cjs +1607 -0
- package/dist/adapters/fastify.cjs.map +1 -0
- package/dist/adapters/fastify.d.cts +49 -0
- package/dist/adapters/fastify.d.mts +49 -0
- package/dist/adapters/fastify.mjs +1606 -0
- package/dist/adapters/fastify.mjs.map +1 -0
- package/dist/adapters/hono.cjs +1651 -0
- package/dist/adapters/hono.cjs.map +1 -0
- package/dist/adapters/hono.d.cts +55 -0
- package/dist/adapters/hono.d.mts +55 -0
- package/dist/adapters/hono.mjs +1650 -0
- package/dist/adapters/hono.mjs.map +1 -0
- package/dist/adapters/nextjs.cjs +1655 -0
- package/dist/adapters/nextjs.cjs.map +1 -0
- package/dist/adapters/nextjs.d.cts +79 -0
- package/dist/adapters/nextjs.d.mts +79 -0
- package/dist/adapters/nextjs.mjs +1651 -0
- package/dist/adapters/nextjs.mjs.map +1 -0
- package/dist/cli/encryption-BA-_xrIW.mjs +45 -0
- package/dist/cli/encryption-CkSveeYj.cjs +45 -0
- package/dist/cli/errors-Bscvlb7X.cjs +45 -0
- package/dist/cli/errors-DL4bkMZV.mjs +40 -0
- package/dist/cli/index.cjs +559 -0
- package/dist/cli/index.mjs +560 -0
- package/dist/cli/phone-5wwAaQ_8.mjs +21 -0
- package/dist/cli/phone-BD4QmEyl.cjs +21 -0
- package/dist/cli/utils-BzEKV3nJ.mjs +30 -0
- package/dist/cli/utils-Dg9Gv_D3.cjs +31 -0
- package/dist/components/react/index.cjs +0 -19
- package/dist/components/react/index.d.cts +1 -2
- package/dist/components/react/index.d.mts +1 -0
- package/dist/components/react/index.mjs +1 -0
- package/dist/express/index.cjs +2201 -0
- package/dist/express/index.cjs.map +1 -0
- package/dist/express/index.d.cts +1322 -0
- package/dist/express/index.d.mts +1322 -0
- package/dist/express/index.mjs +2199 -0
- package/dist/express/index.mjs.map +1 -0
- package/dist/index.cjs +1961 -1088
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1477 -1071
- package/dist/index.d.mts +1907 -0
- package/dist/index.mjs +2050 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +100 -74
- package/dist/components/react/index.cjs.map +0 -1
- package/dist/components/react/index.d.ts +0 -2
- package/dist/components/react/index.js +0 -1
- package/dist/components/react/index.js.map +0 -1
- package/dist/components/react/styles.css +0 -2
- package/dist/components/react/styles.css.map +0 -1
- package/dist/components/react/styles.d.cts +0 -2
- package/dist/components/react/styles.d.ts +0 -2
- package/dist/index.d.ts +0 -1501
- package/dist/index.js +0 -1209
- package/dist/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,177 +1,1050 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- π PATH: README.md -->
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# pesafy π³
|
|
4
|
+
|
|
5
|
+
> **Type-safe M-PESA Daraja SDK** for Node.js, Bun, Deno, Cloudflare Workers, Next.js, Fastify, Hono, and Express.
|
|
4
6
|
|
|
5
7
|
[](https://www.npmjs.com/package/pesafy)
|
|
8
|
+
[](https://www.npmjs.com/package/pesafy)
|
|
6
9
|
[](https://opensource.org/licenses/MIT)
|
|
7
10
|
[](https://www.typescriptlang.org/)
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
---
|
|
10
13
|
|
|
11
|
-
##
|
|
14
|
+
## Table of Contents
|
|
12
15
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
16
|
+
- [Installation](#installation)
|
|
17
|
+
- [Quick Start](#quick-start)
|
|
18
|
+
- [CLI](#cli)
|
|
19
|
+
- [API Reference](#api-reference)
|
|
20
|
+
- [STK Push (M-PESA Express)](#stk-push-m-pesa-express)
|
|
21
|
+
- [C2B (Customer to Business)](#c2b-customer-to-business)
|
|
22
|
+
- [B2C (Business to Customer)](#b2c-business-to-customer)
|
|
23
|
+
- [B2B Express Checkout](#b2b-express-checkout)
|
|
24
|
+
- [Account Balance](#account-balance)
|
|
25
|
+
- [Transaction Status](#transaction-status)
|
|
26
|
+
- [Transaction Reversal](#transaction-reversal)
|
|
27
|
+
- [Tax Remittance (KRA)](#tax-remittance-kra)
|
|
28
|
+
- [Dynamic QR Code](#dynamic-qr-code)
|
|
29
|
+
- [Bill Manager](#bill-manager)
|
|
30
|
+
- [Webhooks](#webhooks)
|
|
31
|
+
- [Framework Adapters](#framework-adapters)
|
|
32
|
+
- [Express](#express-adapter)
|
|
33
|
+
- [Hono (Bun / Cloudflare Workers)](#hono-adapter)
|
|
34
|
+
- [Next.js App Router](#nextjs-adapter)
|
|
35
|
+
- [Fastify](#fastify-adapter)
|
|
36
|
+
- [Branded Types](#branded-types)
|
|
37
|
+
- [Error Handling](#error-handling)
|
|
38
|
+
- [Utilities](#utilities)
|
|
39
|
+
- [Configuration Reference](#configuration-reference)
|
|
40
|
+
- [Roadmap](#roadmap)
|
|
21
41
|
|
|
22
|
-
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
23
45
|
|
|
24
46
|
```bash
|
|
25
|
-
#
|
|
26
|
-
|
|
47
|
+
npm install pesafy # npm
|
|
48
|
+
yarn add pesafy # yarn
|
|
49
|
+
pnpm add pesafy # pnpm
|
|
50
|
+
bun add pesafy # bun
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { Mpesa } from "pesafy";
|
|
27
59
|
|
|
28
|
-
|
|
29
|
-
|
|
60
|
+
const mpesa = new Mpesa({
|
|
61
|
+
consumerKey: process.env.MPESA_CONSUMER_KEY!,
|
|
62
|
+
consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
|
|
63
|
+
environment: "sandbox", // "sandbox" | "production"
|
|
64
|
+
lipaNaMpesaShortCode: "174379",
|
|
65
|
+
lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
|
|
66
|
+
});
|
|
30
67
|
|
|
31
|
-
|
|
32
|
-
|
|
68
|
+
// Send an STK Push
|
|
69
|
+
const response = await mpesa.stkPush({
|
|
70
|
+
amount: 100,
|
|
71
|
+
phoneNumber: "0712345678",
|
|
72
|
+
callbackUrl: "https://yourdomain.com/api/mpesa/callback",
|
|
73
|
+
accountReference: "INV-001",
|
|
74
|
+
transactionDesc: "Payment",
|
|
75
|
+
});
|
|
33
76
|
|
|
34
|
-
|
|
35
|
-
bun add pesafy
|
|
77
|
+
console.log(response.CheckoutRequestID);
|
|
36
78
|
```
|
|
37
79
|
|
|
38
|
-
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## CLI
|
|
83
|
+
|
|
84
|
+
The pesafy CLI lets you interact with Daraja directly from your terminal β great for testing, debugging, and scripting.
|
|
85
|
+
|
|
86
|
+
### Setup
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Interactive setup β creates .env in your project
|
|
90
|
+
npx pesafy init
|
|
91
|
+
|
|
92
|
+
# Validate your .env config
|
|
93
|
+
npx pesafy doctor
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Commands
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
npx pesafy init β Scaffold .env interactively
|
|
100
|
+
npx pesafy doctor β Validate .env for common mistakes
|
|
101
|
+
npx pesafy token β Print a fresh OAuth token
|
|
102
|
+
npx pesafy encrypt β Encrypt initiator password β SecurityCredential
|
|
103
|
+
npx pesafy validate-phone <phone> β Validate / normalise a Kenyan phone number
|
|
104
|
+
npx pesafy stk-push β Initiate an STK Push (interactive prompts)
|
|
105
|
+
npx pesafy stk-push --amount 100 --phone 0712345678 --ref INV-001
|
|
106
|
+
npx pesafy stk-query <checkoutId> β Check STK Push status
|
|
107
|
+
npx pesafy balance β Query M-PESA account balance
|
|
108
|
+
npx pesafy reversal <txId> β Initiate a transaction reversal
|
|
109
|
+
npx pesafy register-c2b-urls β Register C2B Confirmation + Validation URLs
|
|
110
|
+
npx pesafy simulate-c2b β Simulate a C2B payment (sandbox only)
|
|
111
|
+
npx pesafy version β Print library version
|
|
112
|
+
npx pesafy help β Show help
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Environment variables read by the CLI
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
MPESA_CONSUMER_KEY
|
|
119
|
+
MPESA_CONSUMER_SECRET
|
|
120
|
+
MPESA_ENVIRONMENT # sandbox | production
|
|
121
|
+
MPESA_SHORTCODE
|
|
122
|
+
MPESA_PASSKEY
|
|
123
|
+
MPESA_CALLBACK_URL
|
|
124
|
+
MPESA_INITIATOR_NAME
|
|
125
|
+
MPESA_INITIATOR_PASSWORD
|
|
126
|
+
MPESA_CERTIFICATE_PATH # path to .cer file
|
|
127
|
+
MPESA_RESULT_URL
|
|
128
|
+
MPESA_QUEUE_TIMEOUT_URL
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## API Reference
|
|
134
|
+
|
|
135
|
+
### Instantiating the client
|
|
39
136
|
|
|
40
137
|
```typescript
|
|
41
138
|
import { Mpesa } from "pesafy";
|
|
42
139
|
|
|
43
140
|
const mpesa = new Mpesa({
|
|
44
|
-
consumerKey: "
|
|
45
|
-
consumerSecret: "
|
|
141
|
+
consumerKey: "...",
|
|
142
|
+
consumerSecret: "...",
|
|
46
143
|
environment: "sandbox",
|
|
144
|
+
|
|
145
|
+
// STK Push
|
|
47
146
|
lipaNaMpesaShortCode: "174379",
|
|
48
|
-
lipaNaMpesaPassKey: "
|
|
147
|
+
lipaNaMpesaPassKey: "bfb279...",
|
|
148
|
+
|
|
149
|
+
// Initiator-based APIs (B2C, Reversal, Balance, Tax)
|
|
150
|
+
initiatorName: "testapi",
|
|
151
|
+
initiatorPassword: "Safaricom123!",
|
|
152
|
+
certificatePath: "./SandboxCertificate.cer",
|
|
153
|
+
|
|
154
|
+
// HTTP tuning (optional)
|
|
155
|
+
retries: 4, // default: 4
|
|
156
|
+
retryDelay: 2000, // default: 2000 ms
|
|
157
|
+
timeout: 30000, // default: 30 000 ms (per attempt)
|
|
49
158
|
});
|
|
159
|
+
```
|
|
50
160
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
### STK Push (M-PESA Express)
|
|
164
|
+
|
|
165
|
+
Prompts the customer to enter their M-PESA PIN on their phone.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// Initiate
|
|
169
|
+
const push = await mpesa.stkPush({
|
|
170
|
+
amount: 100, // KES β whole numbers only
|
|
171
|
+
phoneNumber: "0712345678", // any Kenyan format
|
|
172
|
+
callbackUrl: "https://yourdomain.com/api/mpesa/callback",
|
|
173
|
+
accountReference: "INV-001", // max 12 chars
|
|
174
|
+
transactionDesc: "Subscription", // max 13 chars
|
|
175
|
+
transactionType: "CustomerPayBillOnline", // default β or "CustomerBuyGoodsOnline"
|
|
176
|
+
partyB: "174379", // defaults to shortCode; set till number for Buy Goods
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
console.log(push.CheckoutRequestID); // save this to query later
|
|
180
|
+
|
|
181
|
+
// Query status
|
|
182
|
+
const status = await mpesa.stkQuery({
|
|
183
|
+
checkoutRequestId: push.CheckoutRequestID,
|
|
58
184
|
});
|
|
185
|
+
|
|
186
|
+
if (status.ResultCode === 0) {
|
|
187
|
+
console.log("Payment confirmed!");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Safe variant β returns Result<T> instead of throwing
|
|
191
|
+
const result = await mpesa.stkPushSafe({ amount: 100, phoneNumber: "0712345678", ... });
|
|
192
|
+
if (result.ok) {
|
|
193
|
+
console.log(result.data.CheckoutRequestID);
|
|
194
|
+
} else {
|
|
195
|
+
console.error(result.error.code, result.error.message);
|
|
196
|
+
}
|
|
59
197
|
```
|
|
60
198
|
|
|
61
|
-
|
|
199
|
+
**Callback payload helpers:**
|
|
62
200
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
201
|
+
```typescript
|
|
202
|
+
import { isStkCallbackSuccess, getCallbackValue, type StkPushCallback } from "pesafy";
|
|
203
|
+
|
|
204
|
+
function handleCallback(body: StkPushCallback) {
|
|
205
|
+
if (isStkCallbackSuccess(body.Body.stkCallback)) {
|
|
206
|
+
const receipt = getCallbackValue(body, "MpesaReceiptNumber"); // string
|
|
207
|
+
const amount = getCallbackValue(body, "Amount"); // number
|
|
208
|
+
const phone = getCallbackValue(body, "PhoneNumber"); // number
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
68
212
|
|
|
69
|
-
|
|
213
|
+
**STK Push ResultCodes:**
|
|
70
214
|
|
|
71
|
-
|
|
215
|
+
| Code | Meaning |
|
|
216
|
+
| ---- | ------------------------ |
|
|
217
|
+
| 0 | Success |
|
|
218
|
+
| 1 | Insufficient balance |
|
|
219
|
+
| 1032 | Cancelled by user |
|
|
220
|
+
| 1037 | DS timeout / unreachable |
|
|
221
|
+
| 2001 | Wrong PIN |
|
|
72
222
|
|
|
73
|
-
|
|
74
|
-
- **STK Query** - Check STK Push transaction status
|
|
75
|
-
- **B2C** - Business to Customer payments
|
|
76
|
-
- **B2B** - Business to Business payments
|
|
77
|
-
- **C2B** - Register URLs & simulate (sandbox)
|
|
78
|
-
- **Dynamic QR Codes** - Generate LIPA NA M-PESA QR codes
|
|
79
|
-
- **Transaction Status** - Query transaction status
|
|
80
|
-
- **Reversal** - Reverse transactions
|
|
223
|
+
---
|
|
81
224
|
|
|
82
|
-
###
|
|
225
|
+
### C2B (Customer to Business)
|
|
83
226
|
|
|
84
|
-
|
|
85
|
-
- **PaymentForm** - Complete form for collecting payment details
|
|
86
|
-
- **QRCode** - Display M-Pesa dynamic QR codes
|
|
87
|
-
- **PaymentStatus** - Show payment status with visual feedback
|
|
227
|
+
Register your Paybill or Till to receive M-PESA payments.
|
|
88
228
|
|
|
89
|
-
|
|
229
|
+
```typescript
|
|
230
|
+
// 1. Register Confirmation + Validation URLs (do this once per shortcode)
|
|
231
|
+
await mpesa.registerC2BUrls({
|
|
232
|
+
shortCode: "600984",
|
|
233
|
+
responseType: "Completed", // "Completed" | "Cancelled"
|
|
234
|
+
confirmationUrl: "https://yourdomain.com/api/mpesa/c2b/confirmation",
|
|
235
|
+
validationUrl: "https://yourdomain.com/api/mpesa/c2b/validation",
|
|
236
|
+
apiVersion: "v2", // default β v2 masks MSISDN in callbacks
|
|
237
|
+
});
|
|
90
238
|
|
|
239
|
+
// 2. Simulate (SANDBOX ONLY)
|
|
240
|
+
await mpesa.simulateC2B({
|
|
241
|
+
shortCode: "600984",
|
|
242
|
+
commandId: "CustomerPayBillOnline", // or "CustomerBuyGoodsOnline"
|
|
243
|
+
amount: 10,
|
|
244
|
+
msisdn: 254708374149,
|
|
245
|
+
billRefNumber: "INV-001", // Paybill only β OMIT for Buy Goods
|
|
246
|
+
apiVersion: "v2",
|
|
247
|
+
});
|
|
91
248
|
```
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
249
|
+
|
|
250
|
+
**Validation webhook handlers:**
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import {
|
|
254
|
+
acceptC2BValidation,
|
|
255
|
+
rejectC2BValidation,
|
|
256
|
+
isC2BPayload,
|
|
257
|
+
getC2BAmount,
|
|
258
|
+
getC2BTransactionId,
|
|
259
|
+
getC2BCustomerName,
|
|
260
|
+
type C2BValidationPayload,
|
|
261
|
+
type C2BConfirmationPayload,
|
|
262
|
+
} from "pesafy";
|
|
263
|
+
|
|
264
|
+
// Validation URL β must respond in β€8 seconds
|
|
265
|
+
app.post("/api/mpesa/c2b/validation", (req, res) => {
|
|
266
|
+
const payload = req.body as C2BValidationPayload;
|
|
267
|
+
const amount = getC2BAmount(payload);
|
|
268
|
+
|
|
269
|
+
if (amount > 100_000) {
|
|
270
|
+
// Reject with specific error code
|
|
271
|
+
return res.json(rejectC2BValidation("C2B00013")); // Invalid Amount
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
res.json(acceptC2BValidation()); // Accept
|
|
275
|
+
// or: res.json(acceptC2BValidation("MY-TX-ID")); // with correlation ID
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Confirmation URL β always respond 200 immediately
|
|
279
|
+
app.post("/api/mpesa/c2b/confirmation", (req, res) => {
|
|
280
|
+
const payload = req.body as C2BConfirmationPayload;
|
|
281
|
+
const txId = getC2BTransactionId(payload);
|
|
282
|
+
const amount = getC2BAmount(payload);
|
|
283
|
+
const name = getC2BCustomerName(payload);
|
|
284
|
+
|
|
285
|
+
// Process async
|
|
286
|
+
processPayment({ txId, amount, name }).catch(console.error);
|
|
287
|
+
|
|
288
|
+
res.json({ ResultCode: 0, ResultDesc: "Success" });
|
|
289
|
+
});
|
|
98
290
|
```
|
|
99
291
|
|
|
100
|
-
|
|
292
|
+
**C2B Validation ResultCodes:**
|
|
101
293
|
|
|
102
|
-
|
|
294
|
+
| Code | Meaning |
|
|
295
|
+
| -------- | ---------------------- |
|
|
296
|
+
| 0 | Accept |
|
|
297
|
+
| C2B00011 | Invalid MSISDN |
|
|
298
|
+
| C2B00012 | Invalid Account Number |
|
|
299
|
+
| C2B00013 | Invalid Amount |
|
|
300
|
+
| C2B00014 | Invalid KYC Details |
|
|
301
|
+
| C2B00015 | Invalid ShortCode |
|
|
302
|
+
| C2B00016 | Other Error |
|
|
103
303
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
### B2C (Business to Customer)
|
|
307
|
+
|
|
308
|
+
Send money to customers or load funds to a B2C shortcode.
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
// BusinessPayToBulk β load funds to a B2C shortcode
|
|
312
|
+
const ack = await mpesa.b2cPayment({
|
|
313
|
+
commandId: "BusinessPayToBulk",
|
|
314
|
+
amount: 50_000,
|
|
315
|
+
partyA: "600979", // your MMF shortcode
|
|
316
|
+
partyB: "600000", // target B2C shortcode
|
|
317
|
+
accountReference: "BATCH-2024-01",
|
|
318
|
+
resultUrl: "https://yourdomain.com/api/mpesa/b2c/result",
|
|
319
|
+
queueTimeOutUrl: "https://yourdomain.com/api/mpesa/b2c/timeout",
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// BusinessPayment β direct payment to customer wallet
|
|
323
|
+
await mpesa.b2cPayment({
|
|
324
|
+
commandId: "BusinessPayment", // or "SalaryPayment" / "PromotionPayment"
|
|
325
|
+
amount: 2500,
|
|
326
|
+
partyA: "600979",
|
|
327
|
+
partyB: "254712345678", // customer MSISDN
|
|
328
|
+
accountReference: "SALARY-JAN",
|
|
329
|
+
resultUrl: "https://yourdomain.com/api/mpesa/b2c/result",
|
|
330
|
+
queueTimeOutUrl: "https://yourdomain.com/api/mpesa/b2c/timeout",
|
|
331
|
+
remarks: "January salary",
|
|
332
|
+
requester: "254712345678", // optional β consumer MSISDN
|
|
333
|
+
});
|
|
334
|
+
```
|
|
107
335
|
|
|
108
|
-
|
|
109
|
-
bun test
|
|
336
|
+
**B2C Result webhook handler:**
|
|
110
337
|
|
|
111
|
-
|
|
112
|
-
|
|
338
|
+
```typescript
|
|
339
|
+
import {
|
|
340
|
+
isB2CResult,
|
|
341
|
+
isB2CSuccess,
|
|
342
|
+
isB2CFailure,
|
|
343
|
+
getB2CTransactionId,
|
|
344
|
+
getB2CAmount,
|
|
345
|
+
getB2CConversationId,
|
|
346
|
+
getB2COriginatorConversationId,
|
|
347
|
+
getB2CReceiverPublicName,
|
|
348
|
+
getB2CDebitAccountBalance,
|
|
349
|
+
} from "pesafy";
|
|
113
350
|
|
|
114
|
-
|
|
115
|
-
|
|
351
|
+
app.post("/api/mpesa/b2c/result", (req, res) => {
|
|
352
|
+
res.json({ ResultCode: 0, ResultDesc: "Accepted" }); // always respond 200 first
|
|
116
353
|
|
|
117
|
-
|
|
118
|
-
bun run format
|
|
354
|
+
if (!isB2CResult(req.body)) return;
|
|
119
355
|
|
|
120
|
-
|
|
121
|
-
|
|
356
|
+
if (isB2CSuccess(req.body)) {
|
|
357
|
+
const txId = getB2CTransactionId(req.body);
|
|
358
|
+
const amount = getB2CAmount(req.body);
|
|
359
|
+
const balance = getB2CDebitAccountBalance(req.body);
|
|
360
|
+
console.log("B2C success:", { txId, amount, balance });
|
|
361
|
+
} else if (isB2CFailure(req.body)) {
|
|
362
|
+
console.error("B2C failed:", req.body.Result.ResultDesc);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
122
365
|
```
|
|
123
366
|
|
|
124
|
-
|
|
367
|
+
**B2C CommandIDs:**
|
|
125
368
|
|
|
126
|
-
|
|
369
|
+
| CommandID | Use Case |
|
|
370
|
+
| ------------------- | ----------------------------- |
|
|
371
|
+
| `BusinessPayToBulk` | Load funds to a B2C shortcode |
|
|
372
|
+
| `BusinessPayment` | Unsecured payment to customer |
|
|
373
|
+
| `SalaryPayment` | Salary disbursement |
|
|
374
|
+
| `PromotionPayment` | Promotions / bonus |
|
|
127
375
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
### B2B Express Checkout
|
|
379
|
+
|
|
380
|
+
Send a USSD Push to a merchant's till for B2B payments.
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
const ack = await mpesa.b2bExpressCheckout({
|
|
384
|
+
primaryShortCode: "000001", // merchant till (debit party)
|
|
385
|
+
receiverShortCode: "000002", // your Paybill (credit party)
|
|
386
|
+
amount: 5000,
|
|
387
|
+
paymentRef: "INV-001",
|
|
388
|
+
callbackUrl: "https://yourdomain.com/api/mpesa/b2b/callback",
|
|
389
|
+
partnerName: "Acme Supplies",
|
|
390
|
+
requestRefId: "unique-uuid-per-request", // auto-generated if omitted
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ack.code === "0" means USSD was initiated
|
|
132
394
|
```
|
|
133
395
|
|
|
134
|
-
|
|
396
|
+
**B2B Callback handler:**
|
|
135
397
|
|
|
136
|
-
|
|
398
|
+
```typescript
|
|
399
|
+
import {
|
|
400
|
+
isB2BCheckoutCallback,
|
|
401
|
+
isB2BCheckoutSuccess,
|
|
402
|
+
isB2BCheckoutCancelled,
|
|
403
|
+
getB2BTransactionId,
|
|
404
|
+
getB2BAmount,
|
|
405
|
+
getB2BConversationId,
|
|
406
|
+
} from "pesafy";
|
|
137
407
|
|
|
138
|
-
|
|
408
|
+
app.post("/api/mpesa/b2b/callback", (req, res) => {
|
|
409
|
+
res.json({ ResultCode: 0, ResultDesc: "Accepted" });
|
|
139
410
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
411
|
+
if (!isB2BCheckoutCallback(req.body)) return;
|
|
412
|
+
|
|
413
|
+
if (isB2BCheckoutSuccess(req.body)) {
|
|
414
|
+
const txId = getB2BTransactionId(req.body);
|
|
415
|
+
const amount = getB2BAmount(req.body);
|
|
416
|
+
console.log("B2B paid:", { txId, amount });
|
|
417
|
+
} else if (isB2BCheckoutCancelled(req.body)) {
|
|
418
|
+
console.log("B2B cancelled by merchant");
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
**B2B Error codes:**
|
|
424
|
+
|
|
425
|
+
| Code | Meaning |
|
|
426
|
+
| ---- | ------------------------ |
|
|
427
|
+
| 0 | Success |
|
|
428
|
+
| 4001 | User cancelled |
|
|
429
|
+
| 4102 | Merchant KYC fail |
|
|
430
|
+
| 4104 | Missing Nominated Number |
|
|
431
|
+
| 4201 | USSD network error |
|
|
432
|
+
| 4203 | USSD exception error |
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
### Account Balance
|
|
437
|
+
|
|
438
|
+
Query the balance of your M-PESA shortcode. **Asynchronous** β result is POSTed to your `resultUrl`.
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
await mpesa.accountBalance({
|
|
442
|
+
partyA: "174379",
|
|
443
|
+
identifierType: "4", // "1"=MSISDN, "2"=Till, "4"=ShortCode
|
|
444
|
+
resultUrl: "https://yourdomain.com/api/mpesa/balance/result",
|
|
445
|
+
queueTimeOutUrl: "https://yourdomain.com/api/mpesa/balance/timeout",
|
|
446
|
+
remarks: "Balance check",
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
**Parsing the result:**
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
import {
|
|
454
|
+
isAccountBalanceSuccess,
|
|
455
|
+
parseAccountBalance,
|
|
456
|
+
getAccountBalanceParam,
|
|
457
|
+
type AccountBalanceResult,
|
|
458
|
+
} from "pesafy";
|
|
459
|
+
|
|
460
|
+
app.post("/api/mpesa/balance/result", (req, res) => {
|
|
461
|
+
res.json({ ResultCode: 0, ResultDesc: "Accepted" });
|
|
462
|
+
|
|
463
|
+
const body = req.body as AccountBalanceResult;
|
|
464
|
+
if (!isAccountBalanceSuccess(body)) return;
|
|
465
|
+
|
|
466
|
+
const raw = getAccountBalanceParam(body, "AccountBalance") as string;
|
|
467
|
+
const accounts = parseAccountBalance(raw ?? "");
|
|
468
|
+
|
|
469
|
+
for (const account of accounts) {
|
|
470
|
+
console.log(`${account.name}: ${account.currency} ${account.amount}`);
|
|
471
|
+
// e.g. "Working Account: KES 45000.00"
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
### Transaction Status
|
|
479
|
+
|
|
480
|
+
Query the result of any completed M-PESA transaction. **Asynchronous**.
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
await mpesa.transactionStatus({
|
|
484
|
+
transactionId: "OEI2AK4XXXX",
|
|
485
|
+
partyA: "174379",
|
|
486
|
+
identifierType: "4",
|
|
487
|
+
resultUrl: "https://yourdomain.com/api/mpesa/tx/result",
|
|
488
|
+
queueTimeOutUrl: "https://yourdomain.com/api/mpesa/tx/timeout",
|
|
489
|
+
remarks: "Check payment status",
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
### Transaction Reversal
|
|
496
|
+
|
|
497
|
+
Reverse a completed M-PESA transaction. **Asynchronous**.
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
await mpesa.reverseTransaction({
|
|
501
|
+
transactionId: "OEI2AK4XXXX",
|
|
502
|
+
receiverParty: "174379",
|
|
503
|
+
receiverIdentifierType: "4", // "1"=MSISDN, "2"=Till, "4"=ShortCode
|
|
504
|
+
amount: 500,
|
|
505
|
+
resultUrl: "https://yourdomain.com/api/mpesa/reversal/result",
|
|
506
|
+
queueTimeOutUrl: "https://yourdomain.com/api/mpesa/reversal/timeout",
|
|
507
|
+
remarks: "Erroneous charge",
|
|
508
|
+
});
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
**Reversal result handler:**
|
|
512
|
+
|
|
513
|
+
```typescript
|
|
514
|
+
import { isReversalSuccess, getReversalTransactionId } from "pesafy";
|
|
515
|
+
|
|
516
|
+
app.post("/api/mpesa/reversal/result", (req, res) => {
|
|
517
|
+
res.json({ ResultCode: 0, ResultDesc: "Accepted" });
|
|
518
|
+
|
|
519
|
+
if (isReversalSuccess(req.body)) {
|
|
520
|
+
console.log("Reversed:", getReversalTransactionId(req.body));
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
### Tax Remittance (KRA)
|
|
528
|
+
|
|
529
|
+
Remit tax to Kenya Revenue Authority via M-PESA. **Asynchronous**.
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
await mpesa.remitTax({
|
|
533
|
+
amount: 5_000,
|
|
534
|
+
partyA: "888880", // your business shortcode
|
|
535
|
+
accountReference: "PRN1234XN", // KRA Payment Registration Number
|
|
536
|
+
resultUrl: "https://yourdomain.com/api/mpesa/tax/result",
|
|
537
|
+
queueTimeOutUrl: "https://yourdomain.com/api/mpesa/tax/timeout",
|
|
538
|
+
remarks: "Monthly PAYE",
|
|
539
|
+
});
|
|
540
|
+
// PartyB is always KRA_SHORTCODE ("572572") β auto-set
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
### Dynamic QR Code
|
|
546
|
+
|
|
547
|
+
Generate an M-PESA QR code customers can scan to pay.
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
const qr = await mpesa.generateDynamicQR({
|
|
551
|
+
merchantName: "My Shop",
|
|
552
|
+
refNo: "INV-001",
|
|
553
|
+
amount: 500,
|
|
554
|
+
trxCode: "BG", // "BG"=Buy Goods, "PB"=Paybill, "WA"=Withdraw, "SM"=Send Money
|
|
555
|
+
cpi: "373132", // till / paybill / MSISDN
|
|
556
|
+
size: 300, // pixels (square)
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Render in HTML:
|
|
560
|
+
// <img src={`data:image/png;base64,${qr.QRCode}`} />
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**QR Transaction codes:**
|
|
564
|
+
|
|
565
|
+
| Code | Use Case |
|
|
566
|
+
| ---- | --------------------------- |
|
|
567
|
+
| `BG` | Pay Merchant (Buy Goods) |
|
|
568
|
+
| `WA` | Withdraw Cash at Agent Till |
|
|
569
|
+
| `PB` | Paybill / Business number |
|
|
570
|
+
| `SM` | Send Money (mobile number) |
|
|
571
|
+
| `SB` | Send to Business |
|
|
145
572
|
|
|
146
|
-
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
### Bill Manager
|
|
576
|
+
|
|
577
|
+
Create and send invoices customers pay via M-PESA.
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
// 1. Opt-in your shortcode (once)
|
|
581
|
+
await mpesa.billManagerOptIn({
|
|
582
|
+
shortcode: "600984",
|
|
583
|
+
email: "billing@company.com",
|
|
584
|
+
officialContact: "0700000000",
|
|
585
|
+
sendReminders: "1",
|
|
586
|
+
logo: "https://cdn.company.com/logo.png",
|
|
587
|
+
callbackUrl: "https://yourdomain.com/api/mpesa/bills/callback",
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// 2. Send a single invoice
|
|
591
|
+
await mpesa.sendInvoice({
|
|
592
|
+
externalReference: "INV-001",
|
|
593
|
+
billingPeriod: "2024-01",
|
|
594
|
+
invoiceName: "January Subscription",
|
|
595
|
+
dueDate: "2024-01-31 23:59:00",
|
|
596
|
+
accountReference: "ACC-12345",
|
|
597
|
+
amount: 2500,
|
|
598
|
+
partyA: "254712345678",
|
|
599
|
+
invoiceItems: [
|
|
600
|
+
{ itemName: "Base subscription", amount: 2000 },
|
|
601
|
+
{ itemName: "SMS bundle", amount: 500 },
|
|
602
|
+
],
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// 3. Bulk invoices (up to 1 000 per call)
|
|
606
|
+
await mpesa.sendBulkInvoices({
|
|
607
|
+
invoices: [
|
|
608
|
+
{ externalReference: "INV-002", billingPeriod: "2024-01", ... },
|
|
609
|
+
{ externalReference: "INV-003", billingPeriod: "2024-01", ... },
|
|
610
|
+
],
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// 4. Cancel an invoice
|
|
614
|
+
await mpesa.cancelInvoice({ externalReference: "INV-001" });
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
### Webhooks
|
|
620
|
+
|
|
621
|
+
**IP verification** (Safaricom always calls from whitelisted IPs):
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
import { verifyWebhookIP, SAFARICOM_IPS } from "pesafy";
|
|
625
|
+
|
|
626
|
+
app.post("/api/mpesa/callback", (req, res) => {
|
|
627
|
+
const ip = req.ip ?? req.headers["x-forwarded-for"];
|
|
628
|
+
if (!verifyWebhookIP(ip)) {
|
|
629
|
+
console.warn("Callback from unknown IP:", ip);
|
|
630
|
+
// Still return 200 β Safaricom will retry if you reject
|
|
631
|
+
}
|
|
632
|
+
// ... process
|
|
633
|
+
res.json({ ResultCode: 0, ResultDesc: "Accepted" });
|
|
634
|
+
});
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
**Generic webhook handler:**
|
|
638
|
+
|
|
639
|
+
```typescript
|
|
640
|
+
import { handleWebhook, isSuccessfulCallback, extractTransactionId, extractAmount } from "pesafy";
|
|
641
|
+
|
|
642
|
+
app.post("/api/mpesa/callback", (req, res) => {
|
|
643
|
+
const result = handleWebhook(req.body, {
|
|
644
|
+
requestIP: req.ip,
|
|
645
|
+
skipIPCheck: false, // set true in local dev
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
if (result.success && isSuccessfulCallback(result.data)) {
|
|
649
|
+
const receipt = extractTransactionId(result.data);
|
|
650
|
+
const amount = extractAmount(result.data);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
res.json({ ResultCode: 0, ResultDesc: "Accepted" });
|
|
654
|
+
});
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
**Retry with backoff** (for your own internal processing):
|
|
658
|
+
|
|
659
|
+
```typescript
|
|
660
|
+
import { retryWithBackoff } from "pesafy";
|
|
661
|
+
|
|
662
|
+
const outcome = await retryWithBackoff(() => saveToDatabase(webhookData), {
|
|
663
|
+
maxRetries: 5,
|
|
664
|
+
initialDelay: 500,
|
|
665
|
+
maxDelay: 30_000,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
if (!outcome.success) {
|
|
669
|
+
console.error("Failed after", outcome.attempts, "attempts");
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
---
|
|
674
|
+
|
|
675
|
+
## Framework Adapters
|
|
676
|
+
|
|
677
|
+
### Express Adapter
|
|
678
|
+
|
|
679
|
+
```typescript
|
|
680
|
+
import express from "express";
|
|
681
|
+
import {
|
|
682
|
+
createMpesaExpressRouter,
|
|
683
|
+
acceptC2BValidation,
|
|
684
|
+
isB2CSuccess,
|
|
685
|
+
getB2CTransactionId,
|
|
686
|
+
} from "pesafy/express";
|
|
687
|
+
|
|
688
|
+
const app = express();
|
|
689
|
+
app.use(express.json());
|
|
690
|
+
|
|
691
|
+
const router = express.Router();
|
|
692
|
+
|
|
693
|
+
createMpesaExpressRouter(router, {
|
|
694
|
+
consumerKey: process.env.MPESA_CONSUMER_KEY!,
|
|
695
|
+
consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
|
|
696
|
+
environment: "sandbox",
|
|
697
|
+
lipaNaMpesaShortCode: "174379",
|
|
698
|
+
lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
|
|
699
|
+
callbackUrl: "https://yourdomain.com/api/mpesa/express/callback",
|
|
700
|
+
|
|
701
|
+
// Initiator (for B2C, Reversal, Balance)
|
|
702
|
+
initiatorName: "testapi",
|
|
703
|
+
initiatorPassword: "Safaricom123!",
|
|
704
|
+
certificatePath: "./SandboxCertificate.cer",
|
|
705
|
+
|
|
706
|
+
// Result endpoints
|
|
707
|
+
resultUrl: "https://yourdomain.com/api/mpesa/transaction-status/result",
|
|
708
|
+
queueTimeOutUrl: "https://yourdomain.com/api/mpesa/timeout",
|
|
709
|
+
|
|
710
|
+
// C2B
|
|
711
|
+
c2bShortCode: "600984",
|
|
712
|
+
c2bConfirmationUrl: "https://yourdomain.com/api/mpesa/c2b/confirmation",
|
|
713
|
+
c2bValidationUrl: "https://yourdomain.com/api/mpesa/c2b/validation",
|
|
714
|
+
onC2BValidation: async (payload) => {
|
|
715
|
+
const amount = Number(payload.TransAmount);
|
|
716
|
+
if (amount > 100_000) return { ResultCode: "C2B00013", ResultDesc: "Rejected" };
|
|
717
|
+
return acceptC2BValidation();
|
|
718
|
+
},
|
|
719
|
+
onC2BConfirmation: async (payload) => {
|
|
720
|
+
await db.payments.create({ txId: payload.TransID, amount: Number(payload.TransAmount) });
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
// B2C
|
|
724
|
+
b2cPartyA: "600979",
|
|
725
|
+
b2cResultUrl: "https://yourdomain.com/api/mpesa/b2c/result",
|
|
726
|
+
b2cQueueTimeOutUrl: "https://yourdomain.com/api/mpesa/b2c/timeout",
|
|
727
|
+
onB2CResult: async (result) => {
|
|
728
|
+
if (isB2CSuccess(result)) {
|
|
729
|
+
await db.disbursements.markCompleted({ txId: getB2CTransactionId(result)! });
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
|
|
733
|
+
skipIPCheck: true, // local dev only
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
app.use("/api", router);
|
|
737
|
+
app.listen(3000);
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
**Routes mounted by `createMpesaExpressRouter`:**
|
|
741
|
+
|
|
742
|
+
| Method | Path | Description |
|
|
743
|
+
| ------ | ---------------------------------- | -------------------------------- |
|
|
744
|
+
| POST | `/mpesa/express/stk-push` | Initiate STK Push |
|
|
745
|
+
| POST | `/mpesa/express/stk-query` | Query STK Push status |
|
|
746
|
+
| POST | `/mpesa/express/callback` | STK Push callback from Safaricom |
|
|
747
|
+
| POST | `/mpesa/transaction-status/query` | Query transaction |
|
|
748
|
+
| POST | `/mpesa/transaction-status/result` | Transaction result callback |
|
|
749
|
+
| POST | `/mpesa/c2b/register-url` | Register C2B URLs |
|
|
750
|
+
| POST | `/mpesa/c2b/simulate` | Simulate C2B (sandbox) |
|
|
751
|
+
| POST | `/mpesa/c2b/validation` | C2B validation callback |
|
|
752
|
+
| POST | `/mpesa/c2b/confirmation` | C2B confirmation callback |
|
|
753
|
+
| POST | `/mpesa/tax/remit` | Initiate tax remittance |
|
|
754
|
+
| POST | `/mpesa/tax/result` | Tax remittance result |
|
|
755
|
+
| POST | `/mpesa/b2b/checkout` | B2B Express Checkout |
|
|
756
|
+
| POST | `/mpesa/b2b/callback` | B2B callback |
|
|
757
|
+
| POST | `/mpesa/b2c/payment` | B2C payment |
|
|
758
|
+
| POST | `/mpesa/b2c/result` | B2C result callback |
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
### Hono Adapter
|
|
763
|
+
|
|
764
|
+
Works on Bun, Cloudflare Workers, Deno, and Node.js via Hono.
|
|
765
|
+
|
|
766
|
+
```typescript
|
|
767
|
+
import { Hono } from "hono";
|
|
768
|
+
import { createMpesaHonoRouter } from "pesafy/adapters/hono";
|
|
769
|
+
|
|
770
|
+
const app = new Hono();
|
|
771
|
+
|
|
772
|
+
createMpesaHonoRouter(app, {
|
|
773
|
+
consumerKey: process.env.MPESA_CONSUMER_KEY!,
|
|
774
|
+
consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
|
|
775
|
+
environment: "sandbox",
|
|
776
|
+
lipaNaMpesaShortCode: "174379",
|
|
777
|
+
lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
|
|
778
|
+
callbackUrl: "https://yourdomain.com/mpesa/express/callback",
|
|
779
|
+
resultUrl: "https://yourdomain.com/mpesa/result",
|
|
780
|
+
queueTimeOutUrl: "https://yourdomain.com/mpesa/timeout",
|
|
781
|
+
|
|
782
|
+
onStkSuccess: async ({ receiptNumber, amount, phone }) => {
|
|
783
|
+
await db.payments.create({ receiptNumber, amount, phone });
|
|
784
|
+
},
|
|
785
|
+
onStkFailure: ({ resultCode, resultDesc }) => {
|
|
786
|
+
console.warn("STK failed:", resultCode, resultDesc);
|
|
787
|
+
},
|
|
788
|
+
skipIPCheck: true,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// Bun
|
|
792
|
+
export default app;
|
|
793
|
+
|
|
794
|
+
// Cloudflare Workers
|
|
795
|
+
export default { fetch: app.fetch };
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
### Next.js Adapter
|
|
801
|
+
|
|
802
|
+
```typescript
|
|
803
|
+
// app/api/mpesa/stk-push/route.ts
|
|
804
|
+
import { createStkPushHandler } from "pesafy/adapters/nextjs";
|
|
805
|
+
|
|
806
|
+
export const POST = createStkPushHandler({
|
|
807
|
+
consumerKey: process.env.MPESA_CONSUMER_KEY!,
|
|
808
|
+
consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
|
|
809
|
+
environment: "sandbox",
|
|
810
|
+
lipaNaMpesaShortCode: process.env.MPESA_SHORTCODE!,
|
|
811
|
+
lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
|
|
812
|
+
callbackUrl: process.env.MPESA_CALLBACK_URL!,
|
|
813
|
+
});
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
```typescript
|
|
817
|
+
// app/api/mpesa/callback/route.ts
|
|
818
|
+
import { createStkCallbackHandler } from "pesafy/adapters/nextjs";
|
|
819
|
+
|
|
820
|
+
export const POST = createStkCallbackHandler({
|
|
821
|
+
consumerKey: process.env.MPESA_CONSUMER_KEY!,
|
|
822
|
+
consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
|
|
823
|
+
environment: "sandbox",
|
|
824
|
+
callbackUrl: process.env.MPESA_CALLBACK_URL!,
|
|
825
|
+
onSuccess: async ({ receiptNumber, amount, phone }) => {
|
|
826
|
+
await db.payments.create({ receiptNumber, amount: amount ?? 0, phone: phone ?? "" });
|
|
827
|
+
},
|
|
828
|
+
onFailure: ({ resultCode, resultDesc }) => {
|
|
829
|
+
console.warn("Payment failed:", resultCode, resultDesc);
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
```typescript
|
|
835
|
+
// app/api/mpesa/stk-query/route.ts
|
|
836
|
+
import { createStkQueryHandler } from "pesafy/adapters/nextjs";
|
|
837
|
+
|
|
838
|
+
export const POST = createStkQueryHandler({
|
|
839
|
+
consumerKey: process.env.MPESA_CONSUMER_KEY!,
|
|
840
|
+
consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
|
|
841
|
+
environment: "sandbox",
|
|
842
|
+
lipaNaMpesaShortCode: process.env.MPESA_SHORTCODE!,
|
|
843
|
+
lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
|
|
844
|
+
callbackUrl: process.env.MPESA_CALLBACK_URL!,
|
|
845
|
+
});
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
---
|
|
849
|
+
|
|
850
|
+
### Fastify Adapter
|
|
851
|
+
|
|
852
|
+
```typescript
|
|
853
|
+
import Fastify from "fastify";
|
|
854
|
+
import { registerMpesaRoutes } from "pesafy/adapters/fastify";
|
|
855
|
+
|
|
856
|
+
const app = Fastify({ logger: true });
|
|
857
|
+
|
|
858
|
+
await registerMpesaRoutes(app, {
|
|
859
|
+
consumerKey: process.env.MPESA_CONSUMER_KEY!,
|
|
860
|
+
consumerSecret: process.env.MPESA_CONSUMER_SECRET!,
|
|
861
|
+
environment: "sandbox",
|
|
862
|
+
lipaNaMpesaShortCode: "174379",
|
|
863
|
+
lipaNaMpesaPassKey: process.env.MPESA_PASSKEY!,
|
|
864
|
+
callbackUrl: "https://yourdomain.com/mpesa/callback",
|
|
865
|
+
resultUrl: "https://yourdomain.com/mpesa/result",
|
|
866
|
+
queueTimeOutUrl: "https://yourdomain.com/mpesa/timeout",
|
|
867
|
+
skipIPCheck: true,
|
|
868
|
+
onStkSuccess: async ({ receiptNumber, amount, phone }) => {
|
|
869
|
+
app.log.info({ receiptNumber, amount, phone }, "Payment received");
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
await app.listen({ port: 3000 });
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
878
|
+
## Branded Types
|
|
879
|
+
|
|
880
|
+
pesafy ships opt-in branded primitives that catch type bugs at compile time β not at runtime.
|
|
881
|
+
|
|
882
|
+
```typescript
|
|
883
|
+
import {
|
|
884
|
+
toKesAmount,
|
|
885
|
+
toMsisdn,
|
|
886
|
+
toPaybill,
|
|
887
|
+
type KesAmount,
|
|
888
|
+
type MsisdnKE,
|
|
889
|
+
type PaybillCode,
|
|
890
|
+
} from "pesafy";
|
|
891
|
+
|
|
892
|
+
const amount: KesAmount = toKesAmount(100); // throws if < 1 or fractional
|
|
893
|
+
const phone: MsisdnKE = toMsisdn("0712345678"); // throws if unparseable
|
|
894
|
+
const code: PaybillCode = toPaybill("174379");
|
|
895
|
+
|
|
896
|
+
// β
Safe β editor shows exact types
|
|
897
|
+
// β Compile error β can't pass plain number where KesAmount is expected
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
**Result type** β prefer this over try/catch in application code:
|
|
901
|
+
|
|
902
|
+
```typescript
|
|
903
|
+
import { ok, err, type Result } from "pesafy";
|
|
147
904
|
|
|
148
|
-
|
|
905
|
+
const result: Result<string> = await mpesa.stkPushSafe({ ... });
|
|
149
906
|
|
|
150
|
-
|
|
907
|
+
if (result.ok) {
|
|
908
|
+
console.log(result.data.CheckoutRequestID);
|
|
909
|
+
} else {
|
|
910
|
+
// result.error is PesafyError with .code, .statusCode, .retryable
|
|
911
|
+
if (result.error.retryable) {
|
|
912
|
+
// schedule retry
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
---
|
|
918
|
+
|
|
919
|
+
## Error Handling
|
|
920
|
+
|
|
921
|
+
All errors are `PesafyError` instances:
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
import { PesafyError, isPesafyError } from "pesafy";
|
|
925
|
+
|
|
926
|
+
try {
|
|
927
|
+
await mpesa.stkPush({ ... });
|
|
928
|
+
} catch (error) {
|
|
929
|
+
if (isPesafyError(error)) {
|
|
930
|
+
console.log(error.code); // "AUTH_FAILED" | "VALIDATION_ERROR" | "API_ERROR" | ...
|
|
931
|
+
console.log(error.message);
|
|
932
|
+
console.log(error.statusCode); // HTTP status (if applicable)
|
|
933
|
+
console.log(error.retryable); // boolean β safe to retry?
|
|
934
|
+
console.log(error.requestId); // Daraja requestId (if returned)
|
|
935
|
+
|
|
936
|
+
// Convenience properties
|
|
937
|
+
error.isValidation; // true if VALIDATION_ERROR
|
|
938
|
+
error.isAuth; // true if AUTH_FAILED / INVALID_CREDENTIALS
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
```
|
|
151
942
|
|
|
152
|
-
|
|
943
|
+
**Error codes:**
|
|
153
944
|
|
|
154
|
-
|
|
945
|
+
| Code | Meaning |
|
|
946
|
+
| --------------------- | ------------------------------------------- |
|
|
947
|
+
| `AUTH_FAILED` | OAuth token fetch failed |
|
|
948
|
+
| `INVALID_CREDENTIALS` | Missing or wrong consumerKey/Secret |
|
|
949
|
+
| `INVALID_PHONE` | Phone number cannot be normalised |
|
|
950
|
+
| `ENCRYPTION_FAILED` | RSA encryption of initiator password failed |
|
|
951
|
+
| `VALIDATION_ERROR` | Invalid request parameters (do not retry) |
|
|
952
|
+
| `API_ERROR` | Daraja returned a 4xx error |
|
|
953
|
+
| `REQUEST_FAILED` | Daraja returned 5xx (retryable) |
|
|
954
|
+
| `NETWORK_ERROR` | DNS / connection failure (retryable) |
|
|
955
|
+
| `TIMEOUT` | Request exceeded timeout (retryable) |
|
|
956
|
+
| `RATE_LIMITED` | 429 Too Many Requests |
|
|
155
957
|
|
|
156
|
-
|
|
157
|
-
|
|
958
|
+
---
|
|
959
|
+
|
|
960
|
+
## Utilities
|
|
158
961
|
|
|
159
|
-
|
|
962
|
+
### Phone number formatting
|
|
160
963
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
964
|
+
```typescript
|
|
965
|
+
import { formatSafaricomPhone } from "pesafy";
|
|
966
|
+
|
|
967
|
+
formatSafaricomPhone("0712345678"); // β "254712345678"
|
|
968
|
+
formatSafaricomPhone("+254712345678"); // β "254712345678"
|
|
969
|
+
formatSafaricomPhone("712345678"); // β "254712345678"
|
|
970
|
+
formatSafaricomPhone("254712345678"); // β "254712345678"
|
|
971
|
+
```
|
|
164
972
|
|
|
165
|
-
|
|
973
|
+
### Security credential encryption
|
|
166
974
|
|
|
167
|
-
|
|
975
|
+
```typescript
|
|
976
|
+
import { encryptSecurityCredential } from "pesafy";
|
|
977
|
+
import { readFileSync } from "fs";
|
|
168
978
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
**Phase 5**: Payment components π
|
|
979
|
+
const pem = readFileSync("./SandboxCertificate.cer", "utf-8");
|
|
980
|
+
const credential = encryptSecurityCredential("Safaricom123!", pem);
|
|
981
|
+
// Pass as config.securityCredential to skip per-call encryption
|
|
982
|
+
```
|
|
174
983
|
|
|
175
984
|
---
|
|
176
985
|
|
|
177
|
-
|
|
986
|
+
## Configuration Reference
|
|
987
|
+
|
|
988
|
+
| Option | Type | Required | Default | Description |
|
|
989
|
+
| ---------------------- | --------------------------- | ------------------------ | ------- | ------------------------------------ |
|
|
990
|
+
| `consumerKey` | `string` | β
| β | Daraja consumer key |
|
|
991
|
+
| `consumerSecret` | `string` | β
| β | Daraja consumer secret |
|
|
992
|
+
| `environment` | `"sandbox" \| "production"` | β
| β | Target environment |
|
|
993
|
+
| `lipaNaMpesaShortCode` | `string` | STK Push | β | Paybill / HO shortcode |
|
|
994
|
+
| `lipaNaMpesaPassKey` | `string` | STK Push | β | LNM passkey |
|
|
995
|
+
| `initiatorName` | `string` | B2C / Reversal / Balance | β | API operator username |
|
|
996
|
+
| `initiatorPassword` | `string` | B2C / Reversal / Balance | β | API operator password |
|
|
997
|
+
| `certificatePath` | `string` | B2C / Reversal / Balance | β | Path to `.cer` file |
|
|
998
|
+
| `certificatePem` | `string` | β | β | PEM string (alternative to path) |
|
|
999
|
+
| `securityCredential` | `string` | β | β | Pre-encrypted credential (skips RSA) |
|
|
1000
|
+
| `retries` | `number` | β | `4` | Retry count on transient errors |
|
|
1001
|
+
| `retryDelay` | `number` | β | `2000` | Base retry delay (ms) |
|
|
1002
|
+
| `timeout` | `number` | β | `30000` | Per-attempt timeout (ms) |
|
|
1003
|
+
|
|
1004
|
+
---
|
|
1005
|
+
|
|
1006
|
+
## Roadmap
|
|
1007
|
+
|
|
1008
|
+
### Planned (Safaricom APIs)
|
|
1009
|
+
|
|
1010
|
+
- [ ] **Standing Orders** β create recurring M-PESA payment instructions
|
|
1011
|
+
- [ ] **M-PESA Global (Send Money)** β international transfers
|
|
1012
|
+
- [ ] **Ratiba (M-PESA Ratiba)** β recurring bill payments
|
|
1013
|
+
- [ ] **M-PESA for Business** β bulk payments improvements
|
|
1014
|
+
- [ ] **Merchant QR** β static QR code generation
|
|
1015
|
+
|
|
1016
|
+
### Planned (Library)
|
|
1017
|
+
|
|
1018
|
+
- [ ] **Idempotency keys** β automatic deduplication headers
|
|
1019
|
+
- [ ] **Webhook signature verification** β once Safaricom ships HMAC support
|
|
1020
|
+
- [ ] **React hooks** β `useStkPush()`, `usePaymentStatus()` with polling
|
|
1021
|
+
- [ ] **Vue composables** β `useStkPush()` for Vue 3
|
|
1022
|
+
- [ ] **OpenAPI spec** β auto-generated from types
|
|
1023
|
+
- [ ] **Mock server** β offline Daraja sandbox for unit testing
|
|
1024
|
+
- [ ] **Zod schemas** β runtime validation of all request / response shapes
|
|
1025
|
+
- [ ] **SvelteKit adapter** β `createMpesaSvelteHandler()`
|
|
1026
|
+
- [ ] **Astro adapter** β API route helpers
|
|
1027
|
+
|
|
1028
|
+
---
|
|
1029
|
+
|
|
1030
|
+
## Contributing
|
|
1031
|
+
|
|
1032
|
+
1. Fork the repository
|
|
1033
|
+
2. Create your feature branch: `git checkout -b feat/my-feature`
|
|
1034
|
+
3. Commit: `git commit -m 'β¨ Add my feature'`
|
|
1035
|
+
4. Push: `git push origin feat/my-feature`
|
|
1036
|
+
5. Open a Pull Request
|
|
1037
|
+
|
|
1038
|
+
---
|
|
1039
|
+
|
|
1040
|
+
## License
|
|
1041
|
+
|
|
1042
|
+
MIT Β© [Lewis Odero](https://github.com/levos-snr)
|
|
1043
|
+
|
|
1044
|
+
---
|
|
1045
|
+
|
|
1046
|
+
## Support
|
|
1047
|
+
|
|
1048
|
+
- π [GitHub Issues](https://github.com/levos-snr/pesafy/issues)
|
|
1049
|
+
- π§ [lewisodero27@gmail.com](mailto:lewisodero27@gmail.com)
|
|
1050
|
+
- π [Daraja Docs](https://developer.safaricom.co.ke)
|