Skip to main content

Direct Charge

Direct Charge lets you collect a mobile money payment from your own app or website. You build the payment screen. Modem Pay handles the networks.

Every payment follows the same 4 steps:

1. Create the payment
2. Read next_step in the response
3. Complete the payment (depends on the network)
4. Verify the final status

Only step 3 changes between networks. Steps 1, 2, and 4 are always the same.

The one rule that matters

A payment is not successful until its status is completed.

The response from creating a payment does not mean the customer paid. It only tells you what to do next. Always confirm the final status with a webhook or a status check (Step 4) before you give value to the customer.

Which flow does my network use?

NetworkFlowWhat happensYou call
WaveRedirectCustomer pays on Wave's page or appNothing. Just wait for the final status
AfriMoneyConfirmCustomer approves a prompt on their phone, then you confirmPOST /payments/confirm/:transactionId
QMoney / APSPINCustomer gives you their PIN/OTP, you submit itPOST /payments/finalize/:transactionId

Find your network in this table first. Then you only need to read one section of Step 3.

Step 1: Create the payment

curl -X POST "https://api.modempay.com/h2h/v1/payments" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"data": {
"amount": 10,
"currency": "GMD",
"network": "afrimoney",
"account_number": "7012345",
"callback_url": "https://yourapp.com/payments/callback"
}
}'
FieldMeaning
amountAmount to charge the customer
currencyCurrently "GMD"
networkThe mobile money network: wave, afrimoney, qmoney, or aps
account_numberThe customer's mobile money number, in local format (no country code)
callback_urlOptional. A URL where Modem Pay sends the result of this payment when it reaches a final status. Use it to get the result for this payment without setting up account-wide webhooks

More optional fields (metadata, external_reference, and others) are covered in the Payment Intent Overview.

Step 2: Read the response

{
"transactionId": "cos-212q2mbrg8z6m",
"payment_intent_id": "8178ffbf...",
"next_step": {
"requiresAuthorization": true,
"auth_mode": "confirm",
"openExternal": false,
"confirmation_steps": "Dial *777#"
}
}
FieldWhat it isWhat to do with it
transactionIdThe ID for this payment attemptSave it. You need it to confirm, finalize, and check status
payment_intent_idThe ID of the overall payment intentSave it for your records and reconciliation
next_stepInstructions for what to do nextRead it to pick your flow in Step 3

How to pick your flow from next_step:

  • openExternal: trueRedirect flow (Case 1)
  • auth_mode: "confirm" and openExternal: falseConfirm flow (Case 2)
  • auth_mode: "pin"PIN flow (Case 3)

Always read next_step from the response. Do not hard-code the flow based on the network name. If a network changes how it works, your integration keeps working.

Step 3: Complete the payment

Case 1: Redirect (Wave)

The response contains:

"next_step": {
"openExternal": true,
"launch_url": "https://pay.wave.com/..."
}

What you do:

  1. Send the customer to launch_url (redirect on web, open in browser on mobile)
  2. The customer completes the payment on Wave
  3. Wait for the final status (Step 4)

Do not call the confirm or finalize endpoints. Wave payments complete on their own. Calling confirm here will return an error.

Case 2: Confirm (AfriMoney)

The response contains:

"next_step": {
"auth_mode": "confirm",
"openExternal": false,
"confirmation_steps": "..."
}

The customer approves the payment on their own phone (USSD prompt or menu). Your app cannot see this happen. That is why you show instructions and let the customer tell you when they are done.

What you do:

  1. Show the instructions from confirmation_steps on your payment screen
  2. Show a button like "I have paid"
  3. When the customer taps it, call:
curl -X POST "https://api.modempay.com/h2h/v1/payments/confirm/cos-212q2mbrg8z6m" \
-H "Authorization: Bearer YOUR_API_KEY"
  1. Wait for the final status (Step 4)

Confirm does not mean success. It tells Modem Pay to check with the network. The payment can still fail (wrong PIN on the customer's phone, not enough balance). Only the final status tells you the truth.

Good to know: Modem Pay also detects the payment automatically. If the customer approves on their phone but never taps your "I have paid" button, Modem Pay's scheduled check with the provider will still pick it up, but that can take up to ~1 minute. The confirm call makes Modem Pay check immediately. So always show the button, and do not treat a missing confirm as a failed payment, trust the final status.

Case 3: PIN / OTP (QMoney, APS)

The response contains:

"next_step": {
"auth_mode": "pin",
"auth_length": 6
}

auth_length tells you how many digits to collect. Use it to size your PIN input instead of hard-coding a length.

What you do:

  1. Show a PIN/OTP input on your payment screen
  2. Submit what the customer enters:
curl -X POST "https://api.modempay.com/h2h/v1/payments/finalize/cos-212q2mbrg8z6m" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"pin": "123456"
}'
  1. The response contains the updated payment status. If it is not yet completed, wait for the final status (Step 4)

Never store the customer's PIN. Send it to the finalize endpoint and forget it.

Step 4: Verify the final status

This is where most integrations go wrong. Do not skip it.

Modem Pay sends an event to your webhook URL when the payment status changes. Mark the payment as paid only when the event shows status: "completed".

Webhooks are best because you get the result the moment it happens, even if the customer closed your app.

If you passed a callback_url when creating the payment, the result for that payment is sent there too. This works the same way, mark the payment as paid only when it shows status: "completed".

See Webhooks for setup and signature verification.

Option 2: Polling

If you cannot receive webhooks, check the status yourself:

curl "https://api.modempay.com/h2h/v1/transactions/cos-212q2mbrg8z6m" \
-H "Authorization: Bearer YOUR_API_KEY"

Poll every 5 seconds. Mobile money can take a minute or two, customers need time to respond to prompts on their phone. Stop polling when the status is final (completed, failed, cancelled, or abandoned) or after 5 minutes.

Payment statuses

StatusMeaningWhat you should do
pendingPayment created, waiting on the customerKeep waiting
processingThe network is processing itKeep waiting. This is not success
completedCustomer paid. Money confirmed✅ Deliver value. This is the only success status
failedThe payment failedCheck failure_reason, show a friendly error, and let the customer try again with a new payment
cancelledThe customer cancelledLet them start a new payment if they want
abandonedThe customer never finishedTreat like cancelled. Start a new payment to retry

A transactionId is for one attempt. To retry after failed, cancelled, or abandoned, create a new payment (Step 1). Do not reuse the old transaction.

Common mistakes

Marking a payment as paid from the Step 1 response. The create response only tells you the payment started. The customer has not paid yet.

Calling confirm for Wave payments. Redirect payments (openExternal: true) complete on their own. Confirm is only for the Confirm flow.

Treating processing as success. It is not final. Wait for completed.

Only checking the finalize response for QMoney. If finalize does not return completed right away, keep verifying with Step 4. Do not assume it failed, and do not assume it succeeded.

Hard-coding the flow per network. Always read next_step. It is the source of truth.

Polling forever, or every second. Poll every 5 seconds, stop after 5 minutes or on a final status.

Quick reference

Create payment → POST /h2h/v1/payments
Read next_step → openExternal? redirect. confirm? show steps. pin? collect PIN
Complete → redirect / POST confirm / POST finalize
Verify → webhook or GET /transactions/:transactionId
Success → status = "completed" and nothing else