MpesaStk
Ruby client for Safaricom Daraja APIs: Lipa na M-Pesa (STK Push), B2C/B2B/C2B, transaction queries, standing orders (Ratiba), IoT SIM portal, IMSI checks, and pull transactions.
Version 3.0 — unified .call API with keyword options. Upgrading from 2.x? See MIGRATION.md.
Requirements: Ruby >= 2.6, Redis (OAuth tokens are cached per consumer key)
Installation
Add to your Gemfile:
gem 'mpesa_stk'
Then:
bundle install
Or install directly:
gem install mpesa_stk
Configuration
Copy the sample environment file and fill in your Daraja credentials:
cp .sample.env .env
require 'mpesa_stk' loads .env via Dotenv when present (skip with ENV['MPESA_STK_SKIP_DOTENV'] = 'true' in Rails and similar apps).
Override settings in code:
MpesaStk.configure do |c|
c[:key] = 'your_consumer_key'
c[:secret] = 'your_consumer_secret'
c[:business_short_code] = '174379'
c[:business_passkey] = 'your_passkey'
c[:callback_url] = 'https://your.app/mpesa/callback'
end
| Variable | Purpose |
|---|---|
base_url |
API host (sandbox or production) |
key, secret |
Consumer key and secret |
business_short_code, business_passkey |
STK / PayBill credentials |
callback_url |
STK Push callback |
confirmation_url |
C2B confirmation URL |
till_number |
Buy Goods till |
initiator, initiator_name, security_credential |
B2C/B2B/status/balance/reversal |
result_url, queue_timeout_url |
Async result endpoints |
iot_api_key, vpn_group, username |
IoT portal (optional) |
Endpoint paths (process_request_url, b2c_url, etc.) default to sandbox values in .sample.env. Change base_url and paths when moving to production.
Redis must be running locally (default redis://127.0.0.1:6379). MpesaStk::AccessToken stores and refreshes bearer tokens automatically.
Quick start
STK Push (PayBill) — one line when .env is configured:
require 'mpesa_stk'
response = MpesaStk::Push.call(100, '254712345678')
# => { "ResponseCode" => "0", "CheckoutRequestID" => "ws_CO_...", ... }
Register your callback_url and handle the STK result on your server (STK Push).
Usage
Every client exposes a single entry point:
.call(...)for payments and queries.register(...)for URL registration (C2B, pull transactions)
Pass keyword options to override .env for a request (credentials, short codes, URLs):
MpesaStk::Push.call(
100,
'254712345678',
business_short_code: '174379',
business_passkey: 'your_passkey',
callback_url: 'https://your.app/callback',
key: 'consumer_key',
secret: 'consumer_secret'
)
| Intent | Call |
|---|---|
| STK PayBill (default) | Push.call(amount, phone) |
| STK Buy Goods | Push.call(amount, phone, type: :buy_goods) |
| B2C payout | B2C.call(amount, phone) |
| STK status | StkPushQuery.call(checkout_request_id) |
Understanding responses
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.
| Layer | Who delivers it | Your action |
|---|---|---|
| Synchronous | Returned by the gem immediately | Store IDs (CheckoutRequestID, ConversationID, etc.) |
| Callback | Safaricom POST to your HTTPS URL |
Update orders, wallets, logs; always respond HTTP 200 |
| Query | Optional follow-up (StkPushQuery, pull API) |
Poll when you did not receive a callback |
On HTTP errors the gem raises StandardError with status and body, for example:
{
"errorCode": "400.001.1001",
"errorMessage": "Bad Request"
}
Sandbox and production payloads follow the same shape; values differ. Confirm against official Daraja docs when integrating a new API version.
API reference
| Class | Entry point | Notes |
|---|---|---|
MpesaStk::Push |
`.call(amount, phone, type: :pay_bill \ | :buy_goods, **options)` |
MpesaStk::StkPushQuery |
.call(checkout_request_id, **options) |
STK payment status |
MpesaStk::B2C |
.call(amount, phone, **options) |
Business → customer |
MpesaStk::B2B |
.call(amount, receiver_party, **options) |
Business → business |
MpesaStk::C2B |
.register(**options), .call(amount, phone, **options) |
URL registration & sandbox simulate |
MpesaStk::TransactionStatus |
.call(transaction_id, **options) |
Transaction status query |
MpesaStk::AccountBalance |
.call(**options) |
Balance (async via result_url) |
MpesaStk::Reversal |
.call(transaction_id, amount, **options) |
Reverse a transaction |
MpesaStk::Ratiba |
.call(amount:, party_a:, start_date:, end_date:, **options) |
Standing orders |
MpesaStk::PullTransactions |
.register(**options), .call(start, end, **options) |
Pull transactions |
MpesaStk::IMSI |
.call(customer_number, version: 'v1', **options) |
IMSI / SIM swap check |
MpesaStk::IoT |
.list_sims, .send_message, .call(:method, ...) |
IoT SIM portal |
MpesaStk::AccessToken |
.call(key, secret) |
OAuth token (usually internal) |
Phone numbers use international format without +, e.g. 254712345678.
STK Push (MpesaStk::Push)
MpesaStk::Push replaces the former PushPayment and Push classes from 2.x.
PayBill (default) — uses business_short_code as PartyB:
MpesaStk::Push.call(100, '254712345678')
Buy Goods — requires till_number in .env or as a keyword:
MpesaStk::Push.call(100, '254712345678', type: :buy_goods)
MpesaStk::Push.call(100, '254712345678', type: :buy_goods, till_number: '174379')
Per-request overrides (multi-app or custom credentials):
MpesaStk::Push.call(
100,
'254712345678',
business_short_code: '174379',
business_passkey: 'your_passkey',
callback_url: 'https://your.app/callback',
key: 'consumer_key',
secret: 'consumer_secret'
)
Immediate response:
{
"MerchantRequestID": "7909-1302368-1",
"CheckoutRequestID": "ws_CO_DMZ_40472724_16062018092359957",
"ResponseCode": "0",
"ResponseDescription": "Success. Request accepted for processing",
"CustomerMessage": "Success. Request accepted for processing"
}
Persist CheckoutRequestID to match the callback and STK query.
Callback (Safaricom POST to callback_url — not returned by the gem):
Success:
{
"Body": {
"stkCallback": {
"MerchantRequestID": "7909-1302368-1",
"CheckoutRequestID": "ws_CO_DMZ_40472724_16062018092359957",
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"CallbackMetadata": {
"Item": [
{ "Name": "Amount", "Value": 100 },
{ "Name": "MpesaReceiptNumber", "Value": "QK4A1B2C3D" },
{ "Name": "TransactionDate", "Value": 20250125143000 },
{ "Name": "PhoneNumber", "Value": 254712345678 }
]
}
}
}
}
Failed or cancelled (no CallbackMetadata; common ResultCode values: 1032 cancelled, 1037 timeout):
{
"Body": {
"stkCallback": {
"MerchantRequestID": "7909-1302368-1",
"CheckoutRequestID": "ws_CO_DMZ_40472724_16062018092359957",
"ResultCode": 1032,
"ResultDesc": "Request cancelled by user."
}
}
}
STK Push query
MpesaStk::StkPushQuery.call('ws_CO_DMZ_40472724_16062018092359957')
Immediate response (accepted query; payment outcome is in ResultCode):
{
"ResponseCode": "0",
"ResponseDescription": "The service request is processed successfully.",
"MerchantRequestID": "7909-1302368-1",
"CheckoutRequestID": "ws_CO_DMZ_40472724_16062018092359957",
"ResultCode": "0",
"ResultDesc": "The service request is processed successfully."
}
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.
B2C
MpesaStk::B2C.call(100, '254712345678', command_id: 'BusinessPayment', remarks: 'Salary')
Immediate response:
{
"ResponseCode": "0",
"ResponseDescription": "The service request is processed successfully.",
"OriginatorConversationID": "12345-67890-1",
"ConversationID": "AG_20240101_12345678901234567890"
}
Callback (result_url / queue_timeout_url):
{
"Result": {
"ResultType": 0,
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"OriginatorConversationID": "12345-67890-1",
"ConversationID": "AG_20240101_12345678901234567890",
"TransactionID": "NLJ41HAAAA",
"ResultParameters": {
"ResultParameter": [
{ "Key": "TransactionAmount", "Value": 100 },
{ "Key": "TransactionReceipt", "Value": "NLJ41HAAAA" },
{ "Key": "ReceiverPartyPublicName", "Value": "254712345678 - John Doe" },
{ "Key": "TransactionDate", "Value": "25.1.2025 12:00:00 AM" }
]
}
}
}
B2B
MpesaStk::B2B.call(500, '600000', command_id: 'BusinessPayBill', account_reference: 'INV-001')
Immediate response (same shape as B2C):
{
"ResponseCode": "0",
"ResponseDescription": "The service request is processed successfully.",
"OriginatorConversationID": "12345-67890-1",
"ConversationID": "AG_20240101_12345678901234567890"
}
Final payment outcome arrives on result_url in the same Result wrapper format as B2C.
C2B
MpesaStk::C2B.register(validation_url: 'https://your.app/validate')
MpesaStk::C2B.call(100, '254712345678', bill_ref_number: 'ORDER-1')
Register URL — immediate response:
{
"ResponseCode": "0",
"ResponseDescription": "success"
}
Simulate — immediate response:
{
"ResponseCode": "0",
"ResponseDescription": "Accept the service request successfully."
}
Confirmation callback (Safaricom POST to confirmation_url on real payments):
{
"TransactionType": "Pay Bill",
"TransID": "RKTQDM7W6S",
"TransTime": "20190608200106",
"TransAmount": "100.00",
"BusinessShortCode": "174379",
"BillRefNumber": "ORDER-1",
"InvoiceNumber": "",
"OrgAccountBalance": "49197.00",
"ThirdPartyTransID": "",
"MSISDN": "254712345678",
"FirstName": "John",
"MiddleName": "",
"LastName": "Doe"
}
Transaction status
MpesaStk::TransactionStatus.call('RKTQDM7W6S')
Immediate response:
{
"ResponseCode": "0",
"ResponseDescription": "The service request is processed successfully.",
"ConversationID": "AG_20240101_12345678901234567890",
"OriginatorConversationID": "12345-67890-1"
}
Result callback (result_url) includes transaction details inside Result.ResultParameters (same pattern as B2C).
Account balance
MpesaStk::AccountBalance.call
Immediate response:
{
"ResponseCode": "0",
"ResponseDescription": "The service request is processed successfully.",
"OriginatorConversationID": "12345-67890-1",
"ConversationID": "AG_20240101_12345678901234567890"
}
Result callback (result_url) — balance in ResultParameters:
{
"Result": {
"ResultType": 0,
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"OriginatorConversationID": "12345-67890-1",
"ConversationID": "AG_20240101_12345678901234567890",
"ResultParameters": {
"ResultParameter": [
{ "Key": "AccountBalance", "Value": "3080.00" },
{ "Key": "BOCompletedTime", "Value": "20250125120000" }
]
}
}
}
Reversal
MpesaStk::Reversal.call('RKTQDM7W6S', 100)
Immediate response:
{
"ResponseCode": "0",
"ResponseDescription": "The service request is processed successfully.",
"OriginatorConversationID": "12345-67890-1",
"ConversationID": "AG_20240101_12345678901234567890"
}
Outcome is confirmed on result_url via the standard Result object.
Ratiba (standing orders)
MpesaStk::Ratiba.call(
amount: 100,
party_a: '254712345678',
start_date: '2025-01-01',
end_date: '2025-12-31',
frequency: '3',
account_reference: 'SUB-001'
)
Immediate response:
{
"ResponseCode": "0",
"ResponseDescription": "The service request is processed successfully.",
"OriginatorConversationID": "12345-67890-1",
"ConversationID": "AG_20240101_12345678901234567890"
}
Standing-order charge results are delivered to callback_url per Daraja Ratiba documentation.
Pull transactions
MpesaStk::PullTransactions.register(request_type: 'Pull', nominated_number: '254712345678')
MpesaStk::PullTransactions.call('2025-01-01 00:00:00', '2025-01-31 23:59:59')
Register — immediate response:
{
"ResponseCode": "0",
"ResponseDescription": "success"
}
Query — immediate response
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:
{
"ResponseCode": "0",
"data": []
}
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). Historical rows may also arrive on your registered callback_url.
IMSI / SIM swap
MpesaStk::IMSI.call('254712345678', version: 'v2')
Immediate response
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).
IoT SIM portal
Shortcuts:
MpesaStk::IoT.list_sims(start_at_index: 0, page_size: 10)
MpesaStk::IoT.('0110100606', 'Hello device')
Or dispatch any instance method by name:
MpesaStk::IoT.call(:query_lifecycle_status, '0110100606')
MpesaStk::IoT.call(:search_messages, 'keyword', page_no: 1, page_size: 5)
List SIMs / messages
Tests stub a minimal success body (again, not a guarantee of empty inventory):
{
"success": true,
"data": []
}
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.
Send message — tests only assert success; a plausible live shape might look like:
{
"success": true,
"data": {
"messageId": "msg-12345",
"status": "sent"
}
}
That send-message example is illustrative; confirm against the IoT portal API docs for your environment.
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.
Development
git clone https://github.com/mboya/mpesa_stk.git
cd mpesa_stk
bin/setup # bundle install
cp .sample.env .env
bundle exec rake test
bundle exec rubocop
Use the Safaricom sandbox and valid test credentials in .env for manual checks.
bundle exec rake test # 75 tests, WebMock (no live API calls)
bundle exec rubocop
Contributing
- Fork the repository and create a feature branch.
- Add tests for new behaviour under
test/. - Run
bundle exec rake testandbundle exec rubocop. - Open a pull request with a clear description.
Please read CODE_OF_CONDUCT.md before participating. Security issues: see SECURITY.md. Release history: CHANGELOG.md.
License
Copyright (c) 2018 mboya. Released under the MIT License.