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?
| Network | Flow | What happens | You call |
|---|---|---|---|
| Wave | Redirect | Customer pays on Wave's page or app | Nothing. Just wait for the final status |
| AfriMoney | Confirm | Customer approves a prompt on their phone, then you confirm | POST /payments/confirm/:transactionId |
| QMoney / APS | PIN | Customer gives you their PIN/OTP, you submit it | POST /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"
}
}'
| Field | Meaning |
|---|---|
amount | Amount to charge the customer |
currency | Currently "GMD" |
network | The mobile money network: wave, afrimoney, qmoney, or aps |
account_number | The customer's mobile money number, in local format (no country code) |
callback_url | Optional. 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#"
}
}
| Field | What it is | What to do with it |
|---|---|---|
transactionId | The ID for this payment attempt | Save it. You need it to confirm, finalize, and check status |
payment_intent_id | The ID of the overall payment intent | Save it for your records and reconciliation |
next_step | Instructions for what to do next | Read it to pick your flow in Step 3 |
How to pick your flow from next_step:
openExternal: true→ Redirect flow (Case 1)auth_mode: "confirm"andopenExternal: false→ Confirm 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:
- Send the customer to
launch_url(redirect on web, open in browser on mobile) - The customer completes the payment on Wave
- 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:
- Show the instructions from
confirmation_stepson your payment screen - Show a button like "I have paid"
- 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"
- 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:
- Show a PIN/OTP input on your payment screen
- 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"
}'
- 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.
Option 1: Webhooks (recommended)
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
| Status | Meaning | What you should do |
|---|---|---|
pending | Payment created, waiting on the customer | Keep waiting |
processing | The network is processing it | Keep waiting. This is not success |
completed | Customer paid. Money confirmed | ✅ Deliver value. This is the only success status |
failed | The payment failed | Check failure_reason, show a friendly error, and let the customer try again with a new payment |
cancelled | The customer cancelled | Let them start a new payment if they want |
abandoned | The customer never finished | Treat 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