Direct debit
View SourceDirect debit lets you collect from a customer repeatedly without prompting them each time. The customer authorises a mandate once; you then charge against it.
The lifecycle is: create → pay → query → cancel.
Create a mandate
ElixirMpesa.direct_debit_creation(%{
"input_AgreedTC" => "1",
"input_CustomerMSISDN" => "255700000000",
"input_ThirdPartyReference" => "SUB-42",
"input_ThirdPartyConversationID" => ElixirMpesa.conversation_id()
})"input_AgreedTC" asserts that the customer accepted the terms and conditions. You are
responsible for actually obtaining that consent and for keeping a record of it.
"input_ThirdPartyReference" is your identifier for the mandate. Keep it — every later
operation needs it.
The customer is prompted on their handset to approve the mandate. Approval is not instant, so treat a successful response as "requested", and confirm with a query before charging.
Collect a payment
ElixirMpesa.direct_debit_payment(%{
"input_Amount" => "10",
"input_CustomerMSISDN" => "255700000000",
"input_ThirdPartyReference" => "SUB-42",
"input_ThirdPartyConversationID" => ElixirMpesa.conversation_id()
})A fresh conversation ID per charge — the mandate reference stays the same, the conversation ID does not. Reusing a conversation ID across two different charges will cause the second to be rejected as a duplicate.
Check a mandate
ElixirMpesa.query_direct_debit(%{
"input_CustomerMSISDN" => "255700000000",
"input_ThirdPartyReference" => "SUB-42"
})Use this before the first charge to confirm the customer approved, and afterwards to detect a mandate the customer has since cancelled from their handset.
Cancel a mandate
ElixirMpesa.direct_debit_cancel(%{
"input_CustomerMSISDN" => "255700000000",
"input_ThirdPartyReference" => "SUB-42",
"input_ThirdPartyConversationID" => ElixirMpesa.conversation_id()
})Cancel when a subscription ends. A customer can also cancel from their handset without telling you, so a charge against a mandate you believe is live can still fail — handle that case rather than assuming it cannot happen.
A subscription loop
defmodule Billing do
require Logger
def charge(subscription) do
attrs = %{
"input_Amount" => to_string(subscription.amount),
"input_CustomerMSISDN" => subscription.msisdn,
"input_ThirdPartyReference" => subscription.mandate_reference,
"input_ThirdPartyConversationID" => ElixirMpesa.conversation_id()
}
case ElixirMpesa.direct_debit_payment(attrs) do
{:ok, response} ->
Subscriptions.record_payment(subscription, response.transaction_id)
{:error, %ElixirMpesa.Error{category: :api} = error} ->
# M-Pesa gave a definite answer: the charge did not happen.
Subscriptions.record_failure(subscription, error.code, Exception.message(error))
{:error, %ElixirMpesa.Error{} = error} ->
# Transport or HTTP failure — the outcome is unknown. Do not retry blindly;
# query the status first. See guides/error-codes.md.
Logger.warning("charge outcome unknown: #{Exception.message(error)}")
Subscriptions.mark_for_reconciliation(subscription)
end
end
end