Skip to content
SkanPay Docs

Cash-in: collect from a wallet

Charge a customer's MTN or Airtel wallet from your checkout, and learn the outcome automatically.

The flow

Sequence
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" }
  }'
  • reference is 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-Key too. 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 network or country: SkanPay works both out from the number. If you get network_required, ask the customer and retry with it.

2. Read the immediate response

201 Created means a charge exists. Look at its status:

statusWhat it meansWhat to do
AWAITING_CUSTOMERThe 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_PROVIDERThe network has not answered yet (it was slow or timed out). SkanPay keeps checking.Same as above. Do not retry with a new key.
SUCCESSFULRare: the network settled instantly.Fulfil (or wait for the webhook, which still arrives).
FAILEDThe 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:

Handling the outcome (pseudo-code)
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 200

Full, 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 FAILED with failure_code: "expired" after about 30 minutes. Until then it is still in progress: a late approval still succeeds.

Why charges fail

failure_codeMeaningTell the customer / do
insufficient_fundsThe wallet does not have enough balance.Ask the customer to top up and try again.
customer_declinedThe customer rejected the prompt.Offer to try again.
customer_unreachableThe handset could not be reached (off, out of coverage).Ask the customer to check their phone and retry.
wallet_not_foundThere is no mobile money wallet on that number.Check the number and the network.
wallet_limit_exceededThe payment would exceed the customer's own telco limit.Try a smaller amount.
wallet_inactiveThe wallet exists but is not active.The customer must contact their network.
invalid_msisdnThe network rejected the number.Check the number.
expiredNo answer arrived in time (about 30 minutes).Treat as not paid. A new attempt needs a new charge.
provider_rejectedThe rail refused the request outright.Retry later with a new Idempotency-Key; contact support if it persists.
provider_unavailableThe rail was down.Retry later.
unknownThe 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.