mpesa_stk 1.3 → 3.0.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.
- checksums.yaml +4 -4
- data/.github/workflows/codeql-analysis.yml +70 -0
- data/.github/workflows/cop.yml +18 -14
- data/.gitignore +2 -0
- data/.rubocop.yml +63 -0
- data/.sample.env +22 -1
- data/CHANGELOG.md +259 -0
- data/CODE_OF_CONDUCT.md +0 -0
- data/Gemfile +6 -2
- data/Gemfile.lock +91 -43
- data/LICENSE.txt +0 -0
- data/MIGRATION.md +77 -0
- data/README.md +536 -97
- data/Rakefile +8 -6
- data/SECURITY.md +21 -0
- data/bin/console +4 -3
- data/bin/index.jpeg +0 -0
- data/lib/mpesa_stk/access_token.rb +54 -21
- data/lib/mpesa_stk/account_balance.rb +30 -0
- data/lib/mpesa_stk/b2b.rb +44 -0
- data/lib/mpesa_stk/b2c.rb +39 -0
- data/lib/mpesa_stk/c2b.rb +44 -0
- data/lib/mpesa_stk/client.rb +67 -0
- data/lib/mpesa_stk/config.rb +36 -0
- data/lib/mpesa_stk/imsi.rb +28 -0
- data/lib/mpesa_stk/iot.rb +167 -0
- data/lib/mpesa_stk/pull_transactions.rb +44 -0
- data/lib/mpesa_stk/push.rb +43 -105
- data/lib/mpesa_stk/ratiba.rb +37 -0
- data/lib/mpesa_stk/reversal.rb +40 -0
- data/lib/mpesa_stk/stk_push_query.rb +38 -0
- data/lib/mpesa_stk/transaction_status.rb +38 -0
- data/lib/mpesa_stk/version.rb +3 -1
- data/lib/mpesa_stk.rb +27 -3
- data/mpesa_stk.gemspec +34 -21
- metadata +126 -44
- data/lib/mpesa_stk/push_payment.rb +0 -71
data/README.md
CHANGED
|
@@ -1,163 +1,602 @@
|
|
|
1
1
|
# MpesaStk
|
|
2
|
-
Lipa na M-Pesa Online Payment API is used to initiate a M-Pesa transaction on behalf of a customer using STK Push. This is the same technique mySafaricom App uses whenever the app is used to make payments.
|
|
3
2
|
|
|
4
|
-
[
|
|
5
|
-
|
|
3
|
+
Ruby client for [Safaricom Daraja](https://developer.safaricom.co.ke/) APIs: Lipa na M-Pesa (STK Push), B2C/B2B/C2B, transaction queries, standing orders (Ratiba), IoT SIM portal, IMSI checks, and pull transactions.
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/rb/mpesa_stk)
|
|
6
|
+
[](https://github.com/mboya/mpesa_stk/actions/workflows/cop.yml)
|
|
7
|
+
|
|
8
|
+
**Version 3.0** — unified `.call` API with keyword options. Upgrading from 2.x? See [MIGRATION.md](MIGRATION.md).
|
|
9
|
+
|
|
10
|
+
**Requirements:** Ruby >= 2.6, [Redis](https://redis.io/) (OAuth tokens are cached per consumer key)
|
|
6
11
|
|
|
7
12
|
## Installation
|
|
8
13
|
|
|
9
|
-
Add
|
|
14
|
+
Add to your Gemfile:
|
|
10
15
|
|
|
11
16
|
```ruby
|
|
12
17
|
gem 'mpesa_stk'
|
|
13
18
|
```
|
|
14
|
-
and run the `bundle install` command
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
Then:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bundle install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or install directly:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
18
29
|
gem install mpesa_stk
|
|
19
30
|
```
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Copy the sample environment file and fill in your Daraja credentials:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cp .sample.env .env
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`require 'mpesa_stk'` loads `.env` via Dotenv when present (skip with `ENV['MPESA_STK_SKIP_DOTENV'] = 'true'` in Rails and similar apps).
|
|
41
|
+
|
|
42
|
+
Override settings in code:
|
|
43
|
+
|
|
23
44
|
```ruby
|
|
24
|
-
|
|
45
|
+
MpesaStk.configure do |c|
|
|
46
|
+
c[:key] = 'your_consumer_key'
|
|
47
|
+
c[:secret] = 'your_consumer_secret'
|
|
48
|
+
c[:business_short_code] = '174379'
|
|
49
|
+
c[:business_passkey] = 'your_passkey'
|
|
50
|
+
c[:callback_url] = 'https://your.app/mpesa/callback'
|
|
51
|
+
end
|
|
25
52
|
```
|
|
26
|
-
|
|
53
|
+
|
|
54
|
+
| Variable | Purpose |
|
|
55
|
+
|----------|---------|
|
|
56
|
+
| `base_url` | API host (sandbox or production) |
|
|
57
|
+
| `key`, `secret` | Consumer key and secret |
|
|
58
|
+
| `business_short_code`, `business_passkey` | STK / PayBill credentials |
|
|
59
|
+
| `callback_url` | STK Push callback |
|
|
60
|
+
| `confirmation_url` | C2B confirmation URL |
|
|
61
|
+
| `till_number` | Buy Goods till |
|
|
62
|
+
| `initiator`, `initiator_name`, `security_credential` | B2C/B2B/status/balance/reversal |
|
|
63
|
+
| `result_url`, `queue_timeout_url` | Async result endpoints |
|
|
64
|
+
| `iot_api_key`, `vpn_group`, `username` | IoT portal (optional) |
|
|
65
|
+
|
|
66
|
+
Endpoint paths (`process_request_url`, `b2c_url`, etc.) default to sandbox values in `.sample.env`. Change `base_url` and paths when moving to production.
|
|
67
|
+
|
|
68
|
+
**Redis** must be running locally (default `redis://127.0.0.1:6379`). `MpesaStk::AccessToken` stores and refreshes bearer tokens automatically.
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
STK Push (PayBill) — one line when `.env` is configured:
|
|
73
|
+
|
|
27
74
|
```ruby
|
|
28
|
-
|
|
75
|
+
require 'mpesa_stk'
|
|
76
|
+
|
|
77
|
+
response = MpesaStk::Push.call(100, '254712345678')
|
|
78
|
+
# => { "ResponseCode" => "0", "CheckoutRequestID" => "ws_CO_...", ... }
|
|
29
79
|
```
|
|
30
|
-
|
|
80
|
+
|
|
81
|
+
Register your `callback_url` and handle the STK result on your server ([STK Push](#stk-push)).
|
|
82
|
+
|
|
83
|
+
## Usage
|
|
84
|
+
|
|
85
|
+
Every client exposes a **single entry point**:
|
|
86
|
+
|
|
87
|
+
- `.call(...)` for payments and queries
|
|
88
|
+
- `.register(...)` for URL registration (C2B, pull transactions)
|
|
89
|
+
|
|
90
|
+
Pass **keyword options** to override `.env` for a request (credentials, short codes, URLs):
|
|
91
|
+
|
|
31
92
|
```ruby
|
|
32
|
-
|
|
93
|
+
MpesaStk::Push.call(
|
|
94
|
+
100,
|
|
95
|
+
'254712345678',
|
|
96
|
+
business_short_code: '174379',
|
|
97
|
+
business_passkey: 'your_passkey',
|
|
98
|
+
callback_url: 'https://your.app/callback',
|
|
99
|
+
key: 'consumer_key',
|
|
100
|
+
secret: 'consumer_secret'
|
|
101
|
+
)
|
|
33
102
|
```
|
|
34
103
|
|
|
35
|
-
|
|
36
|
-
|
|
104
|
+
| Intent | Call |
|
|
105
|
+
|--------|------|
|
|
106
|
+
| STK PayBill (default) | `Push.call(amount, phone)` |
|
|
107
|
+
| STK Buy Goods | `Push.call(amount, phone, type: :buy_goods)` |
|
|
108
|
+
| B2C payout | `B2C.call(amount, phone)` |
|
|
109
|
+
| STK status | `StkPushQuery.call(checkout_request_id)` |
|
|
110
|
+
|
|
111
|
+
## Understanding responses
|
|
112
|
+
|
|
113
|
+
Every successful API call returns a **parsed `Hash`** (JSON keys as strings). In most Daraja APIs, `"ResponseCode" => "0"` means the request was **accepted for processing**, not that money has already moved.
|
|
114
|
+
|
|
115
|
+
| Layer | Who delivers it | Your action |
|
|
116
|
+
|-------|-----------------|-------------|
|
|
117
|
+
| **Synchronous** | Returned by the gem immediately | Store IDs (`CheckoutRequestID`, `ConversationID`, etc.) |
|
|
118
|
+
| **Callback** | Safaricom `POST` to your HTTPS URL | Update orders, wallets, logs; always respond HTTP 200 |
|
|
119
|
+
| **Query** | Optional follow-up (`StkPushQuery`, pull API) | Poll when you did not receive a callback |
|
|
120
|
+
|
|
121
|
+
On HTTP errors the gem raises `StandardError` with status and body, for example:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"errorCode": "400.001.1001",
|
|
126
|
+
"errorMessage": "Bad Request"
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Sandbox and production payloads follow the same shape; values differ. Confirm against [official Daraja docs](https://developer.safaricom.co.ke/Documentation) when integrating a new API version.
|
|
131
|
+
|
|
132
|
+
## API reference
|
|
133
|
+
|
|
134
|
+
| Class | Entry point | Notes |
|
|
135
|
+
|-------|-------------|--------|
|
|
136
|
+
| `MpesaStk::Push` | `.call(amount, phone, type: :pay_bill \| :buy_goods, **options)` | STK Push; PayBill is default |
|
|
137
|
+
| `MpesaStk::StkPushQuery` | `.call(checkout_request_id, **options)` | STK payment status |
|
|
138
|
+
| `MpesaStk::B2C` | `.call(amount, phone, **options)` | Business → customer |
|
|
139
|
+
| `MpesaStk::B2B` | `.call(amount, receiver_party, **options)` | Business → business |
|
|
140
|
+
| `MpesaStk::C2B` | `.register(**options)`, `.call(amount, phone, **options)` | URL registration & sandbox simulate |
|
|
141
|
+
| `MpesaStk::TransactionStatus` | `.call(transaction_id, **options)` | Transaction status query |
|
|
142
|
+
| `MpesaStk::AccountBalance` | `.call(**options)` | Balance (async via `result_url`) |
|
|
143
|
+
| `MpesaStk::Reversal` | `.call(transaction_id, amount, **options)` | Reverse a transaction |
|
|
144
|
+
| `MpesaStk::Ratiba` | `.call(amount:, party_a:, start_date:, end_date:, **options)` | Standing orders |
|
|
145
|
+
| `MpesaStk::PullTransactions` | `.register(**options)`, `.call(start, end, **options)` | Pull transactions |
|
|
146
|
+
| `MpesaStk::IMSI` | `.call(customer_number, version: 'v1', **options)` | IMSI / SIM swap check |
|
|
147
|
+
| `MpesaStk::IoT` | `.list_sims`, `.send_message`, `.call(:method, ...)` | IoT SIM portal |
|
|
148
|
+
| `MpesaStk::AccessToken` | `.call(key, secret)` | OAuth token (usually internal) |
|
|
149
|
+
|
|
150
|
+
Phone numbers use international format without `+`, e.g. `254712345678`.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
### STK Push (`MpesaStk::Push`)
|
|
155
|
+
|
|
156
|
+
`MpesaStk::Push` replaces the former `PushPayment` and `Push` classes from 2.x.
|
|
157
|
+
|
|
158
|
+
**PayBill** (default) — uses `business_short_code` as `PartyB`:
|
|
159
|
+
|
|
37
160
|
```ruby
|
|
38
|
-
|
|
161
|
+
MpesaStk::Push.call(100, '254712345678')
|
|
39
162
|
```
|
|
40
|
-
|
|
163
|
+
|
|
164
|
+
**Buy Goods** — requires `till_number` in `.env` or as a keyword:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
MpesaStk::Push.call(100, '254712345678', type: :buy_goods)
|
|
168
|
+
MpesaStk::Push.call(100, '254712345678', type: :buy_goods, till_number: '174379')
|
|
41
169
|
```
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
170
|
+
|
|
171
|
+
**Per-request overrides** (multi-app or custom credentials):
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
MpesaStk::Push.call(
|
|
175
|
+
100,
|
|
176
|
+
'254712345678',
|
|
177
|
+
business_short_code: '174379',
|
|
178
|
+
business_passkey: 'your_passkey',
|
|
179
|
+
callback_url: 'https://your.app/callback',
|
|
180
|
+
key: 'consumer_key',
|
|
181
|
+
secret: 'consumer_secret'
|
|
182
|
+
)
|
|
48
183
|
```
|
|
49
184
|
|
|
50
|
-
|
|
51
|
-
* `business_short_code` and `business_pass_key` this can be found in [Test Credentials](https://developer.safaricom.co.ke/test_credentials).
|
|
52
|
-
* `callback_url` the url of your application where response will be sent. `make sure its a reachable/active url`
|
|
185
|
+
**Immediate response**:
|
|
53
186
|
|
|
54
|
-
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"MerchantRequestID": "7909-1302368-1",
|
|
190
|
+
"CheckoutRequestID": "ws_CO_DMZ_40472724_16062018092359957",
|
|
191
|
+
"ResponseCode": "0",
|
|
192
|
+
"ResponseDescription": "Success. Request accepted for processing",
|
|
193
|
+
"CustomerMessage": "Success. Request accepted for processing"
|
|
194
|
+
}
|
|
195
|
+
```
|
|
55
196
|
|
|
56
|
-
|
|
197
|
+
Persist `CheckoutRequestID` to match the callback and STK query.
|
|
198
|
+
|
|
199
|
+
**Callback** (Safaricom `POST` to `callback_url` — not returned by the gem):
|
|
200
|
+
|
|
201
|
+
Success:
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"Body": {
|
|
206
|
+
"stkCallback": {
|
|
207
|
+
"MerchantRequestID": "7909-1302368-1",
|
|
208
|
+
"CheckoutRequestID": "ws_CO_DMZ_40472724_16062018092359957",
|
|
209
|
+
"ResultCode": 0,
|
|
210
|
+
"ResultDesc": "The service request is processed successfully.",
|
|
211
|
+
"CallbackMetadata": {
|
|
212
|
+
"Item": [
|
|
213
|
+
{ "Name": "Amount", "Value": 100 },
|
|
214
|
+
{ "Name": "MpesaReceiptNumber", "Value": "QK4A1B2C3D" },
|
|
215
|
+
{ "Name": "TransactionDate", "Value": 20250125143000 },
|
|
216
|
+
{ "Name": "PhoneNumber", "Value": 254712345678 }
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
57
223
|
|
|
58
|
-
|
|
224
|
+
Failed or cancelled (no `CallbackMetadata`; common `ResultCode` values: `1032` cancelled, `1037` timeout):
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"Body": {
|
|
229
|
+
"stkCallback": {
|
|
230
|
+
"MerchantRequestID": "7909-1302368-1",
|
|
231
|
+
"CheckoutRequestID": "ws_CO_DMZ_40472724_16062018092359957",
|
|
232
|
+
"ResultCode": 1032,
|
|
233
|
+
"ResultDesc": "Request cancelled by user."
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
59
238
|
|
|
60
|
-
|
|
239
|
+
---
|
|
61
240
|
|
|
62
|
-
|
|
241
|
+
### STK Push query
|
|
63
242
|
|
|
64
|
-
|
|
243
|
+
```ruby
|
|
244
|
+
MpesaStk::StkPushQuery.call('ws_CO_DMZ_40472724_16062018092359957')
|
|
245
|
+
```
|
|
65
246
|
|
|
66
|
-
|
|
67
|
-
|
|
247
|
+
**Immediate response** (accepted query; payment outcome is in `ResultCode`):
|
|
248
|
+
|
|
249
|
+
```json
|
|
250
|
+
{
|
|
251
|
+
"ResponseCode": "0",
|
|
252
|
+
"ResponseDescription": "The service request is processed successfully.",
|
|
253
|
+
"MerchantRequestID": "7909-1302368-1",
|
|
254
|
+
"CheckoutRequestID": "ws_CO_DMZ_40472724_16062018092359957",
|
|
255
|
+
"ResultCode": "0",
|
|
256
|
+
"ResultDesc": "The service request is processed successfully."
|
|
257
|
+
}
|
|
68
258
|
```
|
|
69
|
-
#### Sample application
|
|
70
|
-
Check out a rails sample application [here](https://github.com/mboya/stk)
|
|
71
259
|
|
|
72
|
-
|
|
73
|
-
|
|
260
|
+
When the customer has not completed payment you may see non-zero `ResultCode` (e.g. `499` pending). Plan to combine query results with the callback for a single source of truth.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
### B2C
|
|
74
265
|
|
|
75
266
|
```ruby
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
267
|
+
MpesaStk::B2C.call(100, '254712345678', command_id: 'BusinessPayment', remarks: 'Salary')
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Immediate response:**
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{
|
|
274
|
+
"ResponseCode": "0",
|
|
275
|
+
"ResponseDescription": "The service request is processed successfully.",
|
|
276
|
+
"OriginatorConversationID": "12345-67890-1",
|
|
277
|
+
"ConversationID": "AG_20240101_12345678901234567890"
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**Callback** (`result_url` / `queue_timeout_url`):
|
|
282
|
+
|
|
283
|
+
```json
|
|
284
|
+
{
|
|
285
|
+
"Result": {
|
|
286
|
+
"ResultType": 0,
|
|
287
|
+
"ResultCode": 0,
|
|
288
|
+
"ResultDesc": "The service request is processed successfully.",
|
|
289
|
+
"OriginatorConversationID": "12345-67890-1",
|
|
290
|
+
"ConversationID": "AG_20240101_12345678901234567890",
|
|
291
|
+
"TransactionID": "NLJ41HAAAA",
|
|
292
|
+
"ResultParameters": {
|
|
293
|
+
"ResultParameter": [
|
|
294
|
+
{ "Key": "TransactionAmount", "Value": 100 },
|
|
295
|
+
{ "Key": "TransactionReceipt", "Value": "NLJ41HAAAA" },
|
|
296
|
+
{ "Key": "ReceiverPartyPublicName", "Value": "254712345678 - John Doe" },
|
|
297
|
+
{ "Key": "TransactionDate", "Value": "25.1.2025 12:00:00 AM" }
|
|
298
|
+
]
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
79
302
|
```
|
|
80
303
|
|
|
81
|
-
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
### B2B
|
|
307
|
+
|
|
82
308
|
```ruby
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
309
|
+
MpesaStk::B2B.call(500, '600000', command_id: 'BusinessPayBill', account_reference: 'INV-001')
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Immediate response** (same shape as B2C):
|
|
313
|
+
|
|
314
|
+
```json
|
|
315
|
+
{
|
|
316
|
+
"ResponseCode": "0",
|
|
317
|
+
"ResponseDescription": "The service request is processed successfully.",
|
|
318
|
+
"OriginatorConversationID": "12345-67890-1",
|
|
319
|
+
"ConversationID": "AG_20240101_12345678901234567890"
|
|
320
|
+
}
|
|
93
321
|
```
|
|
94
|
-
|
|
322
|
+
|
|
323
|
+
Final payment outcome arrives on `result_url` in the same `Result` wrapper format as B2C.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
### C2B
|
|
328
|
+
|
|
95
329
|
```ruby
|
|
96
|
-
|
|
330
|
+
MpesaStk::C2B.register(validation_url: 'https://your.app/validate')
|
|
331
|
+
MpesaStk::C2B.call(100, '254712345678', bill_ref_number: 'ORDER-1')
|
|
97
332
|
```
|
|
98
|
-
|
|
333
|
+
|
|
334
|
+
**Register URL — immediate response:**
|
|
335
|
+
|
|
336
|
+
```json
|
|
337
|
+
{
|
|
338
|
+
"ResponseCode": "0",
|
|
339
|
+
"ResponseDescription": "success"
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
**Simulate — immediate response:**
|
|
344
|
+
|
|
345
|
+
```json
|
|
346
|
+
{
|
|
347
|
+
"ResponseCode": "0",
|
|
348
|
+
"ResponseDescription": "Accept the service request successfully."
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Confirmation callback** (Safaricom `POST` to `confirmation_url` on real payments):
|
|
353
|
+
|
|
354
|
+
```json
|
|
355
|
+
{
|
|
356
|
+
"TransactionType": "Pay Bill",
|
|
357
|
+
"TransID": "RKTQDM7W6S",
|
|
358
|
+
"TransTime": "20190608200106",
|
|
359
|
+
"TransAmount": "100.00",
|
|
360
|
+
"BusinessShortCode": "174379",
|
|
361
|
+
"BillRefNumber": "ORDER-1",
|
|
362
|
+
"InvoiceNumber": "",
|
|
363
|
+
"OrgAccountBalance": "49197.00",
|
|
364
|
+
"ThirdPartyTransID": "",
|
|
365
|
+
"MSISDN": "254712345678",
|
|
366
|
+
"FirstName": "John",
|
|
367
|
+
"MiddleName": "",
|
|
368
|
+
"LastName": "Doe"
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
### Transaction status
|
|
375
|
+
|
|
99
376
|
```ruby
|
|
100
|
-
|
|
377
|
+
MpesaStk::TransactionStatus.call('RKTQDM7W6S')
|
|
101
378
|
```
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
379
|
+
|
|
380
|
+
**Immediate response:**
|
|
381
|
+
|
|
382
|
+
```json
|
|
383
|
+
{
|
|
384
|
+
"ResponseCode": "0",
|
|
385
|
+
"ResponseDescription": "The service request is processed successfully.",
|
|
386
|
+
"ConversationID": "AG_20240101_12345678901234567890",
|
|
387
|
+
"OriginatorConversationID": "12345-67890-1"
|
|
388
|
+
}
|
|
105
389
|
```
|
|
106
390
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
391
|
+
**Result callback** (`result_url`) includes transaction details inside `Result.ResultParameters` (same pattern as B2C).
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
### Account balance
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
MpesaStk::AccountBalance.call
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
**Immediate response:**
|
|
402
|
+
|
|
403
|
+
```json
|
|
404
|
+
{
|
|
405
|
+
"ResponseCode": "0",
|
|
406
|
+
"ResponseDescription": "The service request is processed successfully.",
|
|
407
|
+
"OriginatorConversationID": "12345-67890-1",
|
|
408
|
+
"ConversationID": "AG_20240101_12345678901234567890"
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**Result callback** (`result_url`) — balance in `ResultParameters`:
|
|
413
|
+
|
|
414
|
+
```json
|
|
415
|
+
{
|
|
416
|
+
"Result": {
|
|
417
|
+
"ResultType": 0,
|
|
418
|
+
"ResultCode": 0,
|
|
419
|
+
"ResultDesc": "The service request is processed successfully.",
|
|
420
|
+
"OriginatorConversationID": "12345-67890-1",
|
|
421
|
+
"ConversationID": "AG_20240101_12345678901234567890",
|
|
422
|
+
"ResultParameters": {
|
|
423
|
+
"ResultParameter": [
|
|
424
|
+
{ "Key": "AccountBalance", "Value": "3080.00" },
|
|
425
|
+
{ "Key": "BOCompletedTime", "Value": "20250125120000" }
|
|
426
|
+
]
|
|
427
|
+
}
|
|
115
428
|
}
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
### Reversal
|
|
435
|
+
|
|
436
|
+
```ruby
|
|
437
|
+
MpesaStk::Reversal.call('RKTQDM7W6S', 100)
|
|
116
438
|
```
|
|
117
439
|
|
|
118
|
-
|
|
440
|
+
**Immediate response:**
|
|
119
441
|
|
|
120
|
-
|
|
121
|
-
|
|
442
|
+
```json
|
|
443
|
+
{
|
|
444
|
+
"ResponseCode": "0",
|
|
445
|
+
"ResponseDescription": "The service request is processed successfully.",
|
|
446
|
+
"OriginatorConversationID": "12345-67890-1",
|
|
447
|
+
"ConversationID": "AG_20240101_12345678901234567890"
|
|
448
|
+
}
|
|
449
|
+
```
|
|
122
450
|
|
|
123
|
-
|
|
451
|
+
Outcome is confirmed on `result_url` via the standard `Result` object.
|
|
124
452
|
|
|
125
|
-
|
|
453
|
+
---
|
|
126
454
|
|
|
127
|
-
|
|
455
|
+
### Ratiba (standing orders)
|
|
128
456
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
457
|
+
```ruby
|
|
458
|
+
MpesaStk::Ratiba.call(
|
|
459
|
+
amount: 100,
|
|
460
|
+
party_a: '254712345678',
|
|
461
|
+
start_date: '2025-01-01',
|
|
462
|
+
end_date: '2025-12-31',
|
|
463
|
+
frequency: '3',
|
|
464
|
+
account_reference: 'SUB-001'
|
|
465
|
+
)
|
|
136
466
|
```
|
|
137
467
|
|
|
138
|
-
|
|
468
|
+
**Immediate response:**
|
|
139
469
|
|
|
140
|
-
|
|
470
|
+
```json
|
|
471
|
+
{
|
|
472
|
+
"ResponseCode": "0",
|
|
473
|
+
"ResponseDescription": "The service request is processed successfully.",
|
|
474
|
+
"OriginatorConversationID": "12345-67890-1",
|
|
475
|
+
"ConversationID": "AG_20240101_12345678901234567890"
|
|
476
|
+
}
|
|
477
|
+
```
|
|
141
478
|
|
|
142
|
-
|
|
479
|
+
Standing-order charge results are delivered to `callback_url` per Daraja Ratiba documentation.
|
|
143
480
|
|
|
144
|
-
|
|
481
|
+
---
|
|
145
482
|
|
|
146
|
-
|
|
483
|
+
### Pull transactions
|
|
147
484
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
* Submit the PR after the implementation all unfinished PRs for an issue should have a WIP indicated beside it
|
|
153
|
-
* Every PR should have a link to the issue being solved
|
|
154
|
-
* Checkout this [github best practices](https://github.com/skyscreamer/yoga/wiki/GitHub-Best-Practices) for more info.
|
|
485
|
+
```ruby
|
|
486
|
+
MpesaStk::PullTransactions.register(request_type: 'Pull', nominated_number: '254712345678')
|
|
487
|
+
MpesaStk::PullTransactions.call('2025-01-01 00:00:00', '2025-01-31 23:59:59')
|
|
488
|
+
```
|
|
155
489
|
|
|
156
|
-
|
|
490
|
+
**Register — immediate response:**
|
|
491
|
+
|
|
492
|
+
```json
|
|
493
|
+
{
|
|
494
|
+
"ResponseCode": "0",
|
|
495
|
+
"ResponseDescription": "success"
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
**Query — immediate response**
|
|
500
|
+
|
|
501
|
+
The gem returns whatever JSON Safaricom sends (`JSON.parse` only). The repo does **not** include a captured production/sandbox payload for this endpoint. Tests stub a minimal shape:
|
|
502
|
+
|
|
503
|
+
```json
|
|
504
|
+
{
|
|
505
|
+
"ResponseCode": "0",
|
|
506
|
+
"data": []
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Here `data: []` is a **placeholder** in tests meaning “success, list field exists”—not “the API never returns rows.” When transactions exist for the date range, expect objects inside `data` (field names per [Daraja pull-transactions docs](https://developer.safaricom.co.ke/)). Historical rows may also arrive on your registered `callback_url`.
|
|
511
|
+
|
|
512
|
+
---
|
|
157
513
|
|
|
158
|
-
|
|
514
|
+
### IMSI / SIM swap
|
|
159
515
|
|
|
160
|
-
|
|
516
|
+
```ruby
|
|
517
|
+
MpesaStk::IMSI.call('254712345678', version: 'v2')
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
**Immediate response**
|
|
521
|
+
|
|
522
|
+
Same as above: the gem passes through the API body. Tests stub `{ "success": true, "data": {} }`—`data: {}` is a **placeholder**, not documentation of an empty real response. Live `checkATI` payloads (v1/v2) include swap/IMSI fields inside `data` per Safaricom’s spec. Use this to gate high-risk flows (e.g. extra verification after a recent SIM swap).
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
526
|
+
### IoT SIM portal
|
|
527
|
+
|
|
528
|
+
Shortcuts:
|
|
529
|
+
|
|
530
|
+
```ruby
|
|
531
|
+
MpesaStk::IoT.list_sims(start_at_index: 0, page_size: 10)
|
|
532
|
+
MpesaStk::IoT.send_message('0110100606', 'Hello device')
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Or dispatch any instance method by name:
|
|
536
|
+
|
|
537
|
+
```ruby
|
|
538
|
+
MpesaStk::IoT.call(:query_lifecycle_status, '0110100606')
|
|
539
|
+
MpesaStk::IoT.call(:search_messages, 'keyword', page_no: 1, page_size: 5)
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
**List SIMs / messages**
|
|
543
|
+
|
|
544
|
+
Tests stub a minimal success body (again, **not** a guarantee of empty inventory):
|
|
545
|
+
|
|
546
|
+
```json
|
|
547
|
+
{
|
|
548
|
+
"success": true,
|
|
549
|
+
"data": []
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
With SIMs or messages present, `data` is typically an **array of objects** (MSISDN, status, pagination fields, etc.—per endpoint). Capture one response from your sandbox account and treat that as your reference.
|
|
161
554
|
|
|
162
|
-
|
|
555
|
+
**Send message** — tests only assert success; a plausible live shape might look like:
|
|
556
|
+
|
|
557
|
+
```json
|
|
558
|
+
{
|
|
559
|
+
"success": true,
|
|
560
|
+
"data": {
|
|
561
|
+
"messageId": "msg-12345",
|
|
562
|
+
"status": "sent"
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
That send-message example is **illustrative**; confirm against the IoT portal API docs for your environment.
|
|
568
|
+
|
|
569
|
+
Other IoT methods include `query_lifecycle_status`, `sim_activation`, `get_all_messages`, `filter_messages`, `delete_message`, and `delete_message_thread`. Responses come directly from the portal API and vary by endpoint.
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
## Development
|
|
574
|
+
|
|
575
|
+
```bash
|
|
576
|
+
git clone https://github.com/mboya/mpesa_stk.git
|
|
577
|
+
cd mpesa_stk
|
|
578
|
+
bin/setup # bundle install
|
|
579
|
+
cp .sample.env .env
|
|
580
|
+
bundle exec rake test
|
|
581
|
+
bundle exec rubocop
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Use the [Safaricom sandbox](https://developer.safaricom.co.ke/) and valid test credentials in `.env` for manual checks.
|
|
585
|
+
|
|
586
|
+
```bash
|
|
587
|
+
bundle exec rake test # 75 tests, WebMock (no live API calls)
|
|
588
|
+
bundle exec rubocop
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
## Contributing
|
|
592
|
+
|
|
593
|
+
1. Fork the repository and create a feature branch.
|
|
594
|
+
2. Add tests for new behaviour under `test/`.
|
|
595
|
+
3. Run `bundle exec rake test` and `bundle exec rubocop`.
|
|
596
|
+
4. Open a pull request with a clear description.
|
|
597
|
+
|
|
598
|
+
Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before participating. Security issues: see [SECURITY.md](SECURITY.md). Release history: [CHANGELOG.md](CHANGELOG.md).
|
|
599
|
+
|
|
600
|
+
## License
|
|
163
601
|
|
|
602
|
+
Copyright (c) 2018 mboya. Released under the [MIT License](LICENSE.txt).
|