An Elixir SDK for Airtel Money APIs, providing a clean and idiomatic interface for collections, disbursements, transaction queries, and webhooks.
Features
- Collections - Receive payments from customers (USSD Push)
- Disbursements - Send payments to customers with PIN encryption
- Transfer Status - Check disbursement transfer status
- Transaction Status - Query collection transaction status
- Balance Queries - Check account balance
- OAuth Token Management - Automatic token handling and refresh
- PIN Encryption - RSA encryption for disbursement PINs
- MSISDN Validation - Phone number format validation
- Webhook Verification - HMAC SHA256 signature verification
- Telemetry - Built-in telemetry events for monitoring
- OTP Supervision - Robust supervision tree for production use
- Sandbox & Production - Support for both environments
Installation
Add airtel_money to your list of dependencies in mix.exs:
def deps do
[
{:airtel_money, "~> 0.1.0"}
]
endRun:
mix deps.get
Configuration
Configure the SDK in your config/config.exs:
config :airtel_money,
client_id: "your_client_id",
client_secret: "your_client_secret",
country: "CD",
currency: "CDF",
environment: :sandbox,
webhook_secret: "your_webhook_secret" # Optional, for webhook verificationConfiguration Options
:client_id(required) - Your Airtel Money client ID:client_secret(required) - Your Airtel Money client secret:country(required) - Country code (e.g., "CD" for Democratic Republic of Congo):currency(required) - Currency code (e.g., "CDF" for Congolese Franc):environment(optional) -:sandboxor:production(default::sandbox):host(optional) - Custom API host (overrides default):timeout(optional) - HTTP request timeout in milliseconds (default: 15000):pool_size(optional) - Connection pool size (default: 10):webhook_secret(optional) - Webhook signature secret for verification:rsa_public_key(optional) - RSA public key for PIN encryption (required for disbursements in production)
Usage
Start the Application
The SDK uses OTP supervision. Ensure the application is started:
# In your application.ex
children = [
AirtelMoney.Application
]Collect a Payment
case AirtelMoney.collect(%{
amount: "1000",
msisdn: "2439xxxxxxx",
reference: "INV-001"
}) do
{:ok, result} ->
IO.inspect(result)
{:error, %AirtelMoney.Error{message: message}} ->
IO.puts("Error: #{message}")
endDisburse a Payment
# For production, you need to encrypt the PIN first
case AirtelMoney.Encryption.encrypt_pin("1234") do
{:ok, encrypted_pin} ->
case AirtelMoney.disburse(%{
amount: "5000",
msisdn: "2439xxxxxxx",
reference: "PAY-001",
pin: encrypted_pin
}) do
{:ok, result} ->
IO.inspect(result)
{:error, %AirtelMoney.Error{message: message}} ->
IO.puts("Error: #{message}")
end
{:error, reason} ->
IO.puts("PIN encryption failed: #{reason}")
endQuery Transaction Status
case AirtelMoney.transaction_status("TXN123") do
{:ok, status} ->
IO.inspect(status)
{:error, %AirtelMoney.Error{message: message}} ->
IO.puts("Error: #{message}")
endQuery Balance
case AirtelMoney.balance() do
{:ok, balance} ->
IO.inspect(balance)
{:error, %AirtelMoney.Error{message: message}} ->
IO.puts("Error: #{message}")
endValidate MSISDN
case AirtelMoney.Utils.validate_msisdn("243900000000") do
:ok ->
IO.puts("Valid MSISDN")
{:error, reason} ->
IO.puts("Invalid MSISDN: #{reason}")
endFetch RSA Public Key
case AirtelMoney.Encryption.fetch_public_key() do
{:ok, public_key} ->
IO.puts("Public key fetched successfully")
# Store this key in your config for future use
{:error, error} ->
IO.puts("Failed to fetch public key: #{error.message}")
endWebhooks
The SDK supports both authenticated and unauthenticated webhooks as per the official Airtel Money API documentation.
Webhook Payload Format
Airtel sends transaction status updates to your callback URL with the following format:
{
"transaction": {
"id": "BBZMiscxy",
"message": "Paid KES 5,000 to TECHNOLOGIES LIMITED",
"status_code": "TS",
"airtel_money_id": "MP210603.1234.L06941"
},
"hash": "zITVAAGYSlzl1WkUQJn81kbpT5drH3koffT8jCkcJJA="
}Status codes:
TS- Transaction SuccessTF- Transaction Failed
Using the Plug (Phoenix)
With Authentication (Recommended for Production):
pipeline :webhooks do
plug AirtelMoney.WebhookPlug
end
scope "/webhooks" do
pipe_through :webhooks
post "/airtel", WebhookController, :handle
endWithout Authentication (For Testing):
pipeline :webhooks do
plug AirtelMoney.WebhookPlug, require_auth: false
end
scope "/webhooks" do
pipe_through :webhooks
post "/airtel", WebhookController, :handle
endThe plug will:
- Verify the webhook signature (if
require_auth: true) - Parse the JSON payload
- Assign the parsed data to
conn.assigns[:airtel_webhook] - Return 401 if verification fails (when authentication is enabled)
Webhook Controller Example
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
def handle(conn, _params) do
# The plug already verified the signature and parsed the payload
webhook_data = conn.assigns[:airtel_webhook]
case webhook_data do
%{transaction: %{id: txn_id, status_code: status_code}} ->
# Update your database based on transaction status
if status_code == "TS" do
# Transaction successful - update order, send confirmation
end
if status_code == "TF" do
# Transaction failed - notify customer, handle retry
end
_ ->
IO.puts("Unknown webhook format")
end
send_resp(conn, 200, "OK")
end
endManual Webhook Verification
# Verify webhook signature (hash is extracted from JSON body automatically)
case AirtelMoney.verify_webhook(payload) do
:ok ->
# Signature is valid, process webhook
{:ok, webhook_data} = AirtelMoney.parse_webhook(payload)
# Handle webhook_data
{:error, :invalid_signature} ->
# Invalid signature
{:error, :missing_hash} ->
# Hash not found in payload
{:error, :webhook_secret_not_configured} ->
# Webhook secret not configured
endTelemetry
The SDK emits telemetry events for monitoring:
[:airtel_money, :success]- Successful API request[:airtel_money, :failure]- Failed API request
Attach handlers to monitor events:
:telemetry.attach(
"airtel-money-handler",
[:airtel_money, :success],
&handle_event/4,
nil
)
def handle_event([:airtel_money, event], measurements, metadata, _config) do
IO.puts("Event: #{event}, Duration: #{measurements.duration}ms")
endError Handling
All API functions return {:ok, result} or {:error, %AirtelMoney.Error{}}.
%AirtelMoney.Error{
code: "ERR001",
message: "Invalid request",
status: 400
}Testing
Run tests:
mix test
Run tests with coverage:
mix test.ci
Development
Linting
mix lint
Setup
mix setup
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This project is licensed under the MIT License.
Documentation
Full documentation is available at HexDocs.