Cash-in: collect from a wallet
Charge a customer's MTN or Airtel wallet from your checkout, and learn the outcome automatically.
The flow
Your server SkanPay Customer's phone
│ POST /v1/charges │ │
│────────────────────────▶│ request to MTN / Airtel │
│ │────────────────────────────────▶│ "Enter PIN to pay
│ 201 AWAITING_CUSTOMER │ │ USh 50,000 to Shop"
│◀────────────────────────│ │
│ │ customer approves │
│ │◀────────────────────────────────│
│ webhook │ │
│ charge.successful │ │
│◀────────────────────────│ │
│ 200 OK │ │
│────────────────────────▶│ │1. Create the charge on your server
When the customer confirms their order and enters their phone number, call POST /v1/charges from your backend. Never from the browser: the secret key must stay on the server.
curl -X POST https://api.skanpay.website/v1/charges \
-H "Authorization: Bearer $SKANPAY_SECRET_KEY" \
-H "Idempotency-Key: order-9001" \
-H "Content-Type: application/json" \
-d '{
"amount": "50000",
"currency": "UGX",
"msisdn": "+256772123456",
"reference": "order-9001",
"description": "2 x T-shirt",
"metadata": { "cart_id": "c_42" }
}'referenceis your order id. It comes back on the charge and in every webhook, and is how you find the order again.- Use the order id as the
Idempotency-Keytoo. If the network drops the response and you retry, you get the same charge back instead of a second prompt on the customer's phone. - You usually don't need
networkorcountry: SkanPay works both out from the number. If you getnetwork_required, ask the customer and retry with it.
2. Read the immediate response
201 Created means a charge exists. Look at its status:
| status | What it means | What to do |
|---|---|---|
AWAITING_CUSTOMER | The normal case. The prompt is on the customer's phone. | Show “Check your phone and enter your PIN”. Save the charge id on the order. Wait for the webhook. |
PENDING_PROVIDER | The network has not answered yet (it was slow or timed out). SkanPay keeps checking. | Same as above. Do not retry with a new key. |
SUCCESSFUL | Rare: the network settled instantly. | Fulfil (or wait for the webhook, which still arrives). |
FAILED | The network refused it outright. failure_code says why. | Tell the customer and offer to try again. |
An HTTP error (4xx/5xx) is different: it means no charge was created and nothing reached the customer. See Errors.
3. Show the customer what to do
This screen decides your conversion rate. Good practice:
- Say exactly what will happen: “We've sent a payment request of USh 50,000 to +2567****456. Enter your Mobile Money PIN on your phone to approve.”
- Tell MTN customers who get no prompt to check pending approvals in their MoMo menu, and Airtel customers to check their Airtel Money menu, as prompts can be missed.
- Update the page when the result arrives. Your page can ask your own server every few seconds whether the order is paid; your server knows because the webhook told it. Don't have the browser call SkanPay.
- After about two minutes with no answer, offer “Didn't get the prompt? Try again”, which creates a new charge with a new key.
4. Receive the webhook
SkanPay sends charge.successful or charge.failed to your app's webhook URL as soon as the outcome is known. Verify the signature, then update the order once:
on webhook(event):
verify SkanPay-Signature over the raw body # reject if invalid
if already_processed(event.id): return 200 # deliveries can repeat
charge = event.data.object
order = find_order(charge.reference)
if event.type == "charge.successful" and order.status != "paid":
check charge.amount == order.amount and charge.currency == order.currency
mark order paid, store charge.id, fulfil
if event.type == "charge.failed" and order.status == "awaiting_payment":
mark order payment_failed(charge.failure_code) # never un-pay a paid order
remember(event.id)
return 200Full, runnable handlers in Node.js, Next.js, Python and PHP are on the Webhooks page.
5. Fallback: check the status yourself
If your webhook endpoint was down, or you want to double-check before shipping something valuable, fetch the charge:
curl https://api.skanpay.website/v1/charges/chg_01J9Z3K8Q4W6XK2M7N5P0R3T8V \
-H "Authorization: Bearer $SKANPAY_SECRET_KEY"Polling is the fallback, not the integration: if you poll, do it no more than every 10–15 seconds per charge and stop at a final status. A good pattern is a scheduled job that checks orders still awaiting payment after 10 minutes.
How long does it take?
- The API responds in a few seconds, with the prompt already sent.
- Most customers approve within a minute. The webhook follows within about a minute of the network's answer.
- A charge the customer never answers is closed as
FAILEDwithfailure_code: "expired"after about 30 minutes. Until then it is still in progress: a late approval still succeeds.
Why charges fail
| failure_code | Meaning | Tell the customer / do |
|---|---|---|
insufficient_funds | The wallet does not have enough balance. | Ask the customer to top up and try again. |
customer_declined | The customer rejected the prompt. | Offer to try again. |
customer_unreachable | The handset could not be reached (off, out of coverage). | Ask the customer to check their phone and retry. |
wallet_not_found | There is no mobile money wallet on that number. | Check the number and the network. |
wallet_limit_exceeded | The payment would exceed the customer's own telco limit. | Try a smaller amount. |
wallet_inactive | The wallet exists but is not active. | The customer must contact their network. |
invalid_msisdn | The network rejected the number. | Check the number. |
expired | No answer arrived in time (about 30 minutes). | Treat as not paid. A new attempt needs a new charge. |
provider_rejected | The rail refused the request outright. | Retry later with a new Idempotency-Key; contact support if it persists. |
provider_unavailable | The rail was down. | Retry later. |
unknown | The rail failed the payment without giving a reason. | Treat as not paid. |
Where the money goes
A successful charge credits your pending balance with net (amount minus SkanPay's fee). One day later it moves to available, from which you can pay out or withdraw. See Limits & fees and Balances.
Reversals and refunds
Reversals
Rarely, a network claws back a payment that had succeeded. The charge moves to REVERSED, you receive a charge.reversed webhook, and the amount is taken back out of your balance. Treat it like a chargeback: hold or cancel the order.
Refunds
Checklist
- Charges are created on the server with the secret key.
- Idempotency-Key is the order id (or derived from it), stable across retries.
- Amounts are converted to minor units with the right decimals per currency.
- The customer sees clear “approve on your phone” instructions.
- The webhook handler verifies signatures, is idempotent on event id, and checks the amount.
- A paid order can never be set back to unpaid by a later event.
- Orders still awaiting payment after 30+ minutes are checked with
GET /v1/charges/:id.