ElixirMpesa

Hex.pmHex DocsCIDownloadsLicense

M-Pesa mobile money payments for Elixir. A client for the Vodacom/Vodafone M-Pesa OpenAPI, covering customer-to-business (C2B), business-to-customer (B2C), business-to-business (B2B), reversals, direct debit mandates and transaction queries across Tanzania, Lesotho, Ghana and the DR Congo.

Important

This is the Vodacom M-Pesa OpenAPI (openapi.m-pesa.com). If you are integrating M-Pesa in Kenya, you need Safaricom's Daraja API and a different library — the endpoints, authentication and payloads are unrelated.

{:ok, response} = ElixirMpesa.c2b(%{
"input_Amount" => "10",
"input_CustomerMSISDN" => "255700000000",
"input_TransactionReference" => "INV-1024",
"input_ThirdPartyConversationID" => ElixirMpesa.conversation_id(),
"input_PurchasedItemsDesc" => "Order 1024"
})
response.transaction_id
#=> "49XCD123F6"

No session handshake, no country and currency on every call, no string-keyed error maps.

Installation

def deps do
[{:elixir_mpesa, "~> 0.2.0"}]
end

Requires Elixir 1.15+ and OTP 25+.

Configuration

Credentials are secrets — put them in config/runtime.exs, read from the environment.

import Config
config :elixir_mpesa,
api_type: "sandbox", # "openapi" for production
market: :tanzania,
service_provider_code: System.get_env("MPESA_SERVICE_PROVIDER_CODE"),
api_key: System.fetch_env!("MPESA_API_KEY"),
public_key: System.fetch_env!("MPESA_PUBLIC_KEY")

Get credentials from the M-Pesa OpenAPI Portal.

Supported markets

Setting :market fills in the URL context, country code and currency together, so they cannot drift apart.

:marketCountryURL contextCountry codeCurrency
:tanzaniaTanzaniavodacomTZNTZNTZS
:lesothoLesothovodacomLESLESLSL
:ghanaGhanavodafoneGHAGHAGHS
:drcDR CongovodacomDRCDRCCDF

A market without a preset works too — set url_context, country and currency directly. Sessions are cached per market, so one application can serve several countries at once. See the Markets guide.

Operations

FunctionOperation
c2b/2Customer pays your business
b2c/2Your business pays a customer — refunds, payouts, salaries
b2b/2Your business pays another business
reversal/2Reverse a completed transaction
query_transaction_status/2Look up a transaction
query_beneficiary_name/2Look up the name behind a phone number
direct_debit_creation/2Create a mandate
direct_debit_payment/2Collect against a mandate
query_direct_debit/2Check a mandate
direct_debit_cancel/2Cancel a mandate

Each has a ! variant that returns the response directly and raises on failure.

Sessions are handled for you

The OpenAPI requires a session key obtained by encrypting your API key, exchanging it at getSession, then encrypting the result. ElixirMpesa.Session does this on first use and then caches the key per market, refreshes it before its one-hour expiry, collapses concurrent cache misses into a single getSession call, and re-authenticates once if M-Pesa rejects it mid-flight.

You can still drive it manually — see Authentication.

Error handling

Every function returns {:ok, ElixirMpesa.Response.t()} or {:error, ElixirMpesa.Error.t()}. Match on reason and category:

case ElixirMpesa.c2b(attrs) do
{:ok, response} ->
confirm(response.transaction_id)
# M-Pesa gave a definite answer: the money did not move.
{:error, %ElixirMpesa.Error{category: :api, code: code}} ->
decline(code)
# Timeout or gateway failure — the outcome is unknown. Query, do not resend.
{:error, %ElixirMpesa.Error{category: category}} when category in [:transport, :http] ->
reconcile_later()
end

See Error codes.

Retrying safely

"input_ThirdPartyConversationID" is the idempotency key. Generate one per transaction with ElixirMpesa.conversation_id/0 and reuse the same one when retrying that transaction — M-Pesa uses it to reject the duplicate rather than charge twice.

This is why the library never retries a payment automatically, and refuses to generate a conversation ID for one. Read-only queries get one generated.

Testing

Built on Req, so your suite runs against a stub with no network:

config :elixir_mpesa, req_options: [plug: {Req.Test, ElixirMpesa.Client}]
Req.Test.stub(ElixirMpesa.Client, fn conn ->
Req.Test.json(conn, %{"output_ResponseCode" => "INS-0", "output_TransactionID" => "TX1"})
end)

See the Testing guide.

Documentation

Using an AI coding assistant? llms.txt is a condensed, machine-readable summary of the whole API.

Upgrading from 0.1.0

0.1.0 code keeps working — the old functions remain as deprecated shims until 0.3.0. You should upgrade regardless: 0.1.0 disabled TLS certificate verification on every request, and crashed rather than returning an error tuple on several ordinary HTTP statuses. See Upgrading.

Contributing

Issues and pull requests welcome at github.com/jamesnjovu/elixir_mpesa.

One contribution would be especially valuable: the INS-* response code table. Vodacom publishes it only inside the authenticated developer portal, so this library deliberately does not guess at code meanings. If you have that documentation, adding it to ElixirMpesa.Error would help everyone.

mix deps.get
mix test # or: mix ci — format, credo, dialyzer and tests

License

MIT — see LICENSE.