For AI agents
This is one of the skills paystack_sdk installs for Claude Code (paystack_sdk skills install). The raw file, with its frontmatter, is served at /skills/paystack-sdk-mobile-money.md on this site.
Mobile money charges (Ghana first)
A mobile money charge is merchant-started and payer-approved: you ask Paystack to charge a wallet, the payer gets a prompt on their phone and approves it there, and the result arrives later. Your code must expect "not finished yet" as the normal answer. Statuses and next calls are in paystack-sdk-charge-statuses; this skill is the mobile money specifics.
Start a charge
response = client.charges.mobile_money(
email: "ama@example.com",
amount: 5000, # pesewas: GHS 50.00
currency: "GHS",
reference: reference, # create and store it first
mobile_money: {phone: "0551234987", provider: "mtn"}
)charges.mobile_money checks the mobile_money object (a phone, or an account for an M-PESA Till; a known provider in any case, sent in lowercase) and then calls charges.create. It does not change the hash you pass in.
Provider codes
| Network (Ghana) | provider | Code in banks.list(country: "ghana", type: "mobile_money") |
|---|---|---|
| MTN | mtn | MTN |
| Telecel (formerly Vodafone) | vod | VOD (Paystack's list still names it "Vodafone") |
| AT Money / Airtel Money (AirtelTigo) | atl | ATL |
The charge takes the lowercase provider code; the bank list returns uppercase codes. The SDK accepts either case for mobile_money:. Network brand names change (Telecel Cash, AT Money); the codes above are what Paystack uses. Other providers charges.mobile_money accepts: mpesa, mpesa_offline, mptill (Kenya), orange, wave (Côte d'Ivoire).
The phone number
Paystack's Ghana example uses the local ten-digit form, 0551234987. If you store numbers in E.164 (+233551234987), convert before calling:
def local_ghana_number(phone)
digits = phone.to_s.gsub(/\D/, "") # drop +, spaces and dashes
digits = digits.delete_prefix("233") if digits.size == 12 # +233 551234987 -> 551234987
digits = "0#{digits}" if digits.size == 9 # 551234987 -> 0551234987
raise ArgumentError, "not a Ghana mobile number: #{phone.inspect}" unless digits.match?(/\A0\d{9}\z/)
digits
end
local_ghana_number("+233551234987") # => "0551234987"
local_ghana_number("0551234987") # => "0551234987"What was checked, and what it does not prove: on Paystack's test API, 0551234987, +233551234987, 233551234987, 551234987 and 055 123 4987 were all accepted and all answered success; 0551234 (too short) answered failed. Test mode is lenient, so it cannot tell you which form live mode requires. The docs' example for Kenya recommends the country code (+254...); for Ghana the docs show only the local form. Use the local form for Ghana and confirm the first live charge.
What happens next (live mode, per Paystack's docs)
- The response has
data.statuspay_offlineanddata.display_text. Show the text to the payer. - The payer approves on their phone. They have 180 seconds; after that the charge fails (a network-provider limit).
- Paystack sends the
charge.successwebhook when it succeeds. Verify by reference before giving value. - If no webhook arrives after 180 seconds, call
transactions.verify(reference:)(orcharges.check_pending(reference:)). The reason a charge failed is indata.message.
case response.status
when "pay_offline", "pending"
# show response.display_text, then wait for the webhook (or poll check_pending)
when "success"
# test mode answers like this at once; still verify
when "failed"
# response.original_response.dig("data", "message") says why; start a new charge, new reference
endWhat test mode does not prove
On the test API a mobile money charge for each of mtn, vod and atl, on Paystack's test number 0551234987, returned success immediately and check_pending agreed. That means the call shapes work. It says nothing about live behaviour: whether a given network accepts a merchant-started prompt for a given wallet, whether pay_offline and its display text look as documented, or how long approval takes. Treat live mobile money as unverified until the first real charge, and keep a fallback (such as emailing a payment link) for a network that refuses.
Rules
- Never give value on
pay_offlineor on the response alone. Wait for the webhook and verify by reference withpaid?(amount:, currency:). - Create the
referencebefore the call and store it. After a timeout the charge may exist: verify the reference first, and never repeat the call blindly (the SDK does not retry writes after a timeout). - A new attempt is a new reference. A
failedcharge is finished. - Check
data.status, notsuccess?(see paystack-sdk-charge-statuses).