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.

Gem Version Cop

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.send_message('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

  1. Fork the repository and create a feature branch.
  2. Add tests for new behaviour under test/.
  3. Run bundle exec rake test and bundle exec rubocop.
  4. 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.