Bill payments and VAS
Let wallet customers pay bills, buy airtime and data, and donate from the Scan to Pay biller menu: build the menu in your app with the Remote API, or open the Scan to Pay hosted menu in a webview.
Bill payments (paying a biller or an account), prepaid products (airtime, data) and donations are Value Added Services (VAS) offered to your wallet customers through the Scan to Pay biller menu. They use the same Remote API endpoints as a card payment, with two differences:
- the payee is chosen from a biller menu (or a scanned biller QR), and
- the account reference (and, for some billers, the amount) is collected interactively. The lookup returns
NEED_INPUTuntil every required detail has been supplied; once resolved, atransactionIdis issued and the payment proceeds exactly as for a card payment.
Merchant or biller?This page is for wallet providers whose customers pay bills from a menu inside the wallet. If you are a merchant or biller who wants customers to pay a printed or emailed bill by scanning a QR code, see Bill payment.
Choose an approach
You can integrate bill payments in either of two ways. Both end with the same /generateTransactionId, /purchaseTx and /transactionState calls, and both use the same biller menu configured for your wallet. Choose the approach per wallet.
| Approach | You build | Scan to Pay provides | Best for |
|---|---|---|---|
| A. Native API | Biller menu screens, input forms and validation in your app, and the NEED_INPUT loop | Menu data and lookups through the Remote API | Wallets that want a fully native look and full control of the journey |
| B. Hosted bill menu in a webview | One server call to open a menu session, a webview, and handling of your app deep link | A ready-made, themed menu page that collects and validates the customer's details and runs the NEED_INPUT loop with the biller | Wallets that want to launch bill payments quickly without building menu screens |
The biller menu (which products show, the hierarchy and the images) is configured per wallet by Scan to Pay Operations. Contact [email protected] to enable VAS products for your wallet.
Approach A - Native API
You render the menu and the input forms in your app, and drive the lookup yourself.
End-to-end sequence
| Step | Call | Purpose |
|---|---|---|
| 1 (optional) | GET /menu | Fetch the biller menu to display. |
| 2 (once or more) | POST /generateTransactionId | Start the lookup for the selected biller, then re-call it with the collected inputs until status is SUCCESS. It returns NEED_INPUT until every required input is resolved; the transactionId is issued on SUCCESS. |
| 3 | POST /purchaseTx | Pay with the issued transactionId and one authentication block. |
| 4 | POST /transactionState/{transactionId}/v2 | Poll for the final outcome (and/or use the optional webhook). |
You may use POST /lookup for the step 2 loop instead; /generateTransactionId performs the same lookup and only issues a transactionId once every input is resolved.
Step 1 - Fetch the biller menu (optional)
GET /menu returns three arrays: accountsMenu (billers), prepaidMenu and donationsMenu. Render them dynamically; do not hard-code IDs or names. Each item carries:
idandnameaction:LOOKUPfor a payable biller,MENUfor a sub-menu (its children are inmenus)statusmenuDefinition, with animageand aninputsarray. Validate each input against the suppliedregexbefore sending it.
{
"accountsMenu": [
{
"id": 1024,
"name": "City Utilities",
"action": "LOOKUP",
"status": "ACTIVE",
"menuDefinition": {
"image": "https://cdn.example.com/billers/city-utilities.png",
"inputs": [
{
"ref": "accountNumber",
"title": "Enter your account number",
"type": "NUMBER",
"regex": "^[0-9]{6,20}$",
"regexErrorMessage": "Enter digits only"
}
]
}
}
],
"prepaidMenu": [],
"donationsMenu": []
}If all three arrays are empty, VAS has not been enabled for your wallet.
Step 2 - Start the lookup
Call /generateTransactionId with value set to the biller reference. For a menu selection, use "MENU-<id>" (for example "MENU-1024"); you may also pass a scanned biller QR value directly. Include msisdn and deviceData. If the biller needs more information, you receive status: "NEED_INPUT".
curl -X POST https://qa.scantopay.io/pluto/remote/v4/generateTransactionId \
-u 'wallet_username:wallet_password' \
-H 'Content-Type: application/json' \
-d '{
"msisdn": "27830000000",
"value": "MENU-1024",
"deviceData": { "os": "Android", "appVersion": "5.2.1", "clientIP": "196.0.2.10" }
}'Response - HTTP 200, NEED_INPUT:
{
"status": "NEED_INPUT",
"merchantName": "City Utilities",
"value": "MENU-1024",
"inputsRequired": [
{
"ref": "accountNumber",
"title": "Enter your account number",
"type": "NUMBER",
"regex": "^[0-9]{6,20}$",
"regexErrorMessage": "Enter digits only"
}
]
}Each entry in inputsRequired describes one prompt: ref, title, and type (NUMBER, NUMERIC, MSISDN, DROPDOWN or TEXT), with an optional regex and regexErrorMessage.
Step 2 (continued) - Resolve NEED_INPUT
NEED_INPUTPrompt the customer for each entry in inputsRequired, then re-call the same endpoint with an inputs array of { "ref", "value" }. Observe these rules:
- Use the exact
refnames returned ininputsRequired(for example the account ref, andamountwhen the biller asks for it). - Echo the
valuereturned in the response on the next call; it may have been rewritten. This applies to the lookup //generateTransactionIdcalls only;/purchaseTxuses thetransactionId. - Repeat until
statusisSUCCESS. Some billers ask for the account first and the amount on a later step.
curl -X POST https://qa.scantopay.io/pluto/remote/v4/generateTransactionId \
-u 'wallet_username:wallet_password' \
-H 'Content-Type: application/json' \
-d '{
"msisdn": "27830000000",
"value": "MENU-1024",
"inputs": [ { "ref": "accountNumber", "value": "272257910281" } ],
"deviceData": { "os": "Android", "appVersion": "5.2.1", "clientIP": "196.0.2.10" }
}'Step 2 result - SUCCESS (transaction issued)
SUCCESS (transaction issued)On SUCCESS you receive a transactionId and the payment data. For bill payments this typically includes amount, currencyCode, merchantReference and acceptedPaymentMethods. When the customer may edit or part-pay the amount, it also includes partialPayment: true with amountOptions (min / max). Honour those limits before submitting the payment.
{
"status": "SUCCESS",
"transactionId": 988456123,
"merchantName": "City Utilities",
"value": "0123456789",
"data": {
"amount": 480.00,
"currency": "R",
"currencyCode": "ZAR",
"partialPayment": true,
"amountOptions": { "min": 10.00, "max": 5000.00 },
"merchantReference": "ACC-272257910281",
"acceptedPaymentMethods": "AMT,SECURE_CODE",
"shortDescription": "City Utilities account payment"
}
}Step 3 - Pay, then step 4 - track
Call /purchaseTx with the transactionId and one authentication block, exactly as for a card payment (see Purchase flow): pinData for a PIN / account (AMT) payment, or secureCodeData / secureCodeBasic for 3D Secure. Branch on acceptedPaymentMethods from step 2 (or call /binLookup).
The call returns PENDING; poll /transactionState/{transactionId}/v2 for the final outcome. A declined or failed bill payment surfaces as a terminal END_* state, not as a /purchaseTx error. See Remote API responses and errors.
Bill-payment errors
During the lookup steps, a biller may reject the details. These return HTTP 400 with status: "ERROR". Where the message comes from the biller it varies, so display it to the customer.
| Trigger condition | HTTP | message |
|---|---|---|
| Account or biller details rejected by the biller | 400 | Varies: biller-supplied message |
| Amount outside the biller's permitted limits | 400 | Varies: biller-supplied message (respect amountOptions) |
| Biller temporarily unavailable | 400 | Service not available, please try again later. |
| Biller code or QR not recognised | 400 | Code not supported |
Neither clientIdentifier nor msisdn supplied | 400 | Validation error: Either 'clientIdentifier' or 'msisdn' is required |
| Payment declined after submission | 200 | Terminal END_* state via /transactionState |
Bill-payment specifics
- Amounts may be editable or partial. When
partialPaymentistrue, let the customer enter an amount withinamountOptions(min/max) and submit that amount. - Tips do not apply to bill payments.
- Each successful lookup issues a fresh, single-use
transactionId. If the customer changes the account or the amount, run the lookup again. - Some billers restrict the accepted card issuers. A restriction surfaces as a validation error during payment or as a terminal decline; show the returned message to the customer.
Approach B - Hosted bill menu in a webview
Scan to Pay hosts the biller menu as a web page in your wallet's look and feel. Your app opens it in a webview, and the customer picks a biller or product and enters the details the biller asks for. Scan to Pay validates the details and completes the NEED_INPUT loop with the biller. When the bill is resolved, the page hands a payment code back to your app through your app's deep link. Your app closes the webview and pays the code with the same calls as Approach A.
You do not render menus, input forms or biller messages. You only:
- create a menu session from your server,
- open the returned page in a webview,
- handle your deep link, and
- pay the code.
End-to-end sequence
Wallet app Wallet backend Scan to Pay
────────── ────────────── ───────────
│ open bill payments │ │
├─────────────────────►│ POST /menu/session │
│ ├────────────────────────►│
│ │◄─── url, expiresIn ─────┤
│◄──── url ────────────┤ │
│ │
│ open url in a webview │
├───────────────────────────────────────────────►│
│ customer picks a biller and enters details; │
│ Scan to Pay runs the lookup with the biller │
│◄──── your deep link (payment code + state) ────┤
│ close the webview │ │
│ │ │
│ payment code │ /generateTransactionId │
├─────────────────────►├────────────────────────►│
│ │ /purchaseTx │
│ ├────────────────────────►│
│ │ /transactionState (poll)
│ ├────────────────────────►│
| Step | Where | Call / action | Purpose |
|---|---|---|---|
| 1 | Your server | POST /menu/session | Create a single-use page URL for this customer. |
| 2 | Your app | Open the returned url in a webview | The customer picks a biller and enters the details; Scan to Pay runs the lookup with the biller. |
| 3 | Your app | Receive your deep link | The page returns the resolved payment code (and your state) to your app; close the webview. |
| 4 | Your server or app | POST /generateTransactionId | Issue the transactionId for the payment code. |
| 5 | Your server or app | POST /purchaseTx | Pay, exactly as in Approach A step 3. |
| 6 | Your server | POST /transactionState/{transactionId}/v2 | Poll for the final outcome, exactly as in Approach A step 4. |
Before you start
Scan to Pay sets up the following with you:
| Item | What is needed |
|---|---|
| Hosted menu enabled | Scan to Pay enables the hosted menu for your wallet. You use the same Remote API credentials as for your other calls; no new credentials are issued. |
| Your app deep link | One deep link per platform (Android, iOS) that opens your app with a payment code. Mark where the code goes with the placeholder {paymentReference}; Scan to Pay fills it in. Example: yourwallet://pay?code={paymentReference}. Only app schemes and https links are accepted (not http, javascript, data, file or intent links). A {returnUrl} placeholder, if your link already has one, is left empty. |
| Your biller menu | The page shows the same biller menu that GET /menu returns for your wallet. Menu changes made by Scan to Pay apply to both approaches. |
| Your theme (optional) | The colours, font and corner radius below. Anything not supplied uses the Scan to Pay default look. Logos and custom CSS are not supported. |
Theme settings:
| Setting | Format | Used for |
|---|---|---|
header | Colour, #RRGGBB | Top bar background |
onHeader | Colour, #RRGGBB | Title and back arrow on the top bar |
primary | Colour, #RRGGBB | Buttons, list chevrons, focused input border |
onPrimary | Colour, #RRGGBB | Text on buttons |
background | Colour, #RRGGBB | Page background |
surface | Colour, #RRGGBB | Cards, list rows and input fields |
text | Colour, #RRGGBB | Main text |
mutedText | Colour, #RRGGBB | Subtitles, hints and icons |
font | system, sans-serif, serif or rounded | Font family |
radius | Whole number, 0 to 24 | Corner radius of cards, inputs and buttons (pixels) |
Step 1 - Create a menu session (your server)
Call POST /menu/session from your back end, authenticated exactly like your other Remote API calls, each time the customer opens bill payments. Never call it from the app: your Remote API credentials must not be on the device.
| Field | Required | Format | Description |
|---|---|---|---|
msisdn | One of msisdn / clientIdentifier | ^[1-9]\d{8,16} | Customer's mobile number in international format, as on your other calls. |
clientIdentifier | One of msisdn / clientIdentifier | Up to 100 characters | Your customer identifier, if you use one instead of msisdn. |
menuGroup | Optional | ACCOUNTS, PREPAID or DONATIONS | Show only this group (for example only airtime and data). Leave it out to show every group that has items. |
state | Optional, recommended | ^[A-Za-z0-9_-]{16,128}$ | Your own random value for this session. It comes back on your deep link in step 3 so you can match the result to this customer. Do not put personal data in it. |
curl -X POST https://qa.scantopay.io/pluto/remote/v4/menu/session \
-u 'wallet_username:wallet_password' \
-H 'Content-Type: application/json' \
-d '{
"msisdn": "27830000000",
"state": "f3a9c1e07b2d4c58a1e6"
}'Response - HTTP 200, SUCCESS:
{
"status": "SUCCESS",
"url": "https://<scan-to-pay-host>/pluto/web/menu?lt=<one-time launch token>",
"expiresIn": 120
}urlis the page to open in step 2. Always use it as returned; do not build or change it.expiresInis the number of seconds within which theurlmust be opened (120 by default).- The response is sent with
Cache-Control: no-store.
Errors:
| Trigger condition | HTTP | message |
|---|---|---|
Neither clientIdentifier nor msisdn supplied | 400 | Validation error: Either 'clientIdentifier' or 'msisdn' is required |
A field does not match its format (for example state) | 400 | 'state' must match "^[A-Za-z0-9_-]{16,128}$" |
| Hosted menu not enabled, or no usable deep link set up for your wallet | 400 | Validation error: Web menu not available for this wallet |
No billers configured for your wallet, or none in the requested menuGroup | 400 | Validation error: No Menu Configured |
Customer exists but is not in ACTIVE state | 400 | Client Suspended |
| Not authenticated | 401 | Security-layer default |
| Your wallet is not enabled for the hosted menu | 403 | You are not authorized to access this resource |
The url is a one-time secretIt works once, and only within
expiresInseconds. Open it immediately and create a new session every time the customer opens bill payments. Do not log, cache, store or share the url, and do not send it to analytics.
Step 2 - Open the page in a webview (your app)
Load url as the top-level page of an in-app webview (Android WebView, iOS WKWebView). From here the customer works in the page: they choose bills, airtime / data or donations, pick a provider, and enter the details the biller asks for. Field validation, biller messages (for example an unknown account number) and further prompts are all handled by the page. Your app does nothing until step 3.
Webview requirements:
- JavaScript enabled. Cookies and web storage are not needed.
- Keep the standard User-Agent of the platform's webview. Scan to Pay uses it to choose your Android or iOS deep link (an iPad counts as iOS). If your wallet has no deep link for the platform, the customer sees "This page is no longer available."
- Open it in the webview itself, not in an iframe and not in the external browser. The page refuses to be shown inside a frame, and opening it in an external browser uses up the single-use url outside your app.
- Provide your own Close / Back control around the webview. The first screen of the page has no close button. If the customer closes the webview, nothing else is needed; the session simply expires.
- Send the Android back button to the webview first. When the customer presses back, call the webview's
canGoBack()and, if it returnstrue,goBack(); close the webview only when it returnsfalse. The page then steps back one screen at a time, like its own back arrow, and on its first screencanGoBack()isfalse. On iOS, the same applies if you enable back / forward swipe gestures on the webview. - Allow HTTPS to the Scan to Pay host and to
httpsbiller logo images.
What the customer may see:
| Situation | Page shows | What your app does |
|---|---|---|
| Biller rejects the details (for example an invalid account) | The biller's message; the customer can correct and retry | Nothing |
| Temporary error | "Something went wrong. Please try again." | Nothing |
| No activity for 10 minutes, or 30 minutes since step 1 | "This page has expired." | Close the webview; start again at step 1 |
| Too many attempts in one session (20 lookups) | "This page has expired." | Close the webview; start again at step 1 |
url opened a second time, or after expiresIn | "This page is no longer available. Close it and start again from your app." | Close the webview; start again at step 1 |
Step 3 - Receive the payment code (your app)
When the biller accepts the details, the page shows "Returning to <your wallet>..." and navigates to your deep link for the platform, with the payment code filled in and your state added as a query parameter:
yourwallet://pay?code=0123456789&state=f3a9c1e07b2d4c58a1e6
Your app must:
- Intercept the navigation to your scheme in the webview's URL-loading callback, and not let the webview try to load it.
- Read the payment code from the position of
{paymentReference}in your link, and readstate. - Check
stateequals the value you sent in step 1 for this customer. If it does not, ignore the result. - Close the webview.
- Continue with step 4, treating the code like a scanned biller QR code.
The payment code is not a payment. Nothing is charged until /purchaseTx in step 5. If the customer changes their mind after step 3, discard the code and start again at step 1.
Step 4 - Create the transaction
Call /generateTransactionId with value set to the payment code, the same msisdn or clientIdentifier as in step 1, and deviceData. Because the biller details were collected on the page, the response is normally SUCCESS straight away; if a biller still returns NEED_INPUT, handle it as in Approach A step 2.
curl -X POST https://qa.scantopay.io/pluto/remote/v4/generateTransactionId \
-u 'wallet_username:wallet_password' \
-H 'Content-Type: application/json' \
-d '{
"msisdn": "27830000000",
"value": "0123456789",
"deviceData": { "os": "Android", "appVersion": "5.2.1", "clientIP": "196.0.2.10" }
}'Response - HTTP 200, SUCCESS (transaction issued):
{
"status": "SUCCESS",
"transactionId": 988456123,
"merchantName": "City Utilities",
"value": "0123456789",
"data": {
"amount": 480.00,
"currencyCode": "ZAR",
"partialPayment": true,
"amountOptions": { "min": 10.00, "max": 5000.00 },
"acceptedPaymentMethods": "AMT,SECURE_CODE"
}
}Show the customer the merchant and amount and, where partialPayment is true, let them choose an amount within amountOptions, as in Approach A.
Step 5 - Pay, then step 6 - track
Identical to Approach A steps 3 and 4: call /purchaseTx with the transactionId and one authentication block chosen from acceptedPaymentMethods, then poll /transactionState/{transactionId}/v2 for the final outcome. Card entry, PIN entry and 3D Secure stay in your app; they never happen inside the hosted page.
Hosted menu specifics
- One session per visit. Create a new session each time the customer opens bill payments. A session ends when a bill is resolved, when it expires, or after 20 lookups.
- Your app receives only the payment code. The biller chosen and the details typed by the customer stay with Scan to Pay; the transaction details come back on
/generateTransactionId. - The page shows only your wallet's billers, limited to
menuGroupwhen you send one. - No cancel call is needed. If the customer leaves, close the webview; the session expires on its own.
- Same customer throughout. Use the same
msisdn/clientIdentifierin steps 1 and 4.
What's next
- Pay and authenticate the transaction → Purchase flow
- Every response, error message and terminal state → Remote API responses and errors
- Integration rules → Remote API business rules
- Merchant-side bill QR codes → Bill payment
Updated about 2 hours ago
