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_INPUT until every required detail has been supplied; once resolved, a transactionId is 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.

ApproachYou buildScan to Pay providesBest for
A. Native APIBiller menu screens, input forms and validation in your app, and the NEED_INPUT loopMenu data and lookups through the Remote APIWallets that want a fully native look and full control of the journey
B. Hosted bill menu in a webviewOne server call to open a menu session, a webview, and handling of your app deep linkA ready-made, themed menu page that collects and validates the customer's details and runs the NEED_INPUT loop with the billerWallets 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

StepCallPurpose
1 (optional)GET /menuFetch the biller menu to display.
2 (once or more)POST /generateTransactionIdStart 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.
3POST /purchaseTxPay with the issued transactionId and one authentication block.
4POST /transactionState/{transactionId}/v2Poll 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:

  • id and name
  • action: LOOKUP for a payable biller, MENU for a sub-menu (its children are in menus)
  • status
  • menuDefinition, with an image and an inputs array. Validate each input against the supplied regex before 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

Prompt 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 ref names returned in inputsRequired (for example the account ref, and amount when the biller asks for it).
  • Echo the value returned in the response on the next call; it may have been rewritten. This applies to the lookup / /generateTransactionId calls only; /purchaseTx uses the transactionId.
  • Repeat until status is SUCCESS. 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)

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 conditionHTTPmessage
Account or biller details rejected by the biller400Varies: biller-supplied message
Amount outside the biller's permitted limits400Varies: biller-supplied message (respect amountOptions)
Biller temporarily unavailable400Service not available, please try again later.
Biller code or QR not recognised400Code not supported
Neither clientIdentifier nor msisdn supplied400Validation error: Either 'clientIdentifier' or 'msisdn' is required
Payment declined after submission200Terminal END_* state via /transactionState

Bill-payment specifics

  • Amounts may be editable or partial. When partialPayment is true, let the customer enter an amount within amountOptions (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:

  1. create a menu session from your server,
  2. open the returned page in a webview,
  3. handle your deep link, and
  4. 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)
        │                      ├────────────────────────►│
StepWhereCall / actionPurpose
1Your serverPOST /menu/sessionCreate a single-use page URL for this customer.
2Your appOpen the returned url in a webviewThe customer picks a biller and enters the details; Scan to Pay runs the lookup with the biller.
3Your appReceive your deep linkThe page returns the resolved payment code (and your state) to your app; close the webview.
4Your server or appPOST /generateTransactionIdIssue the transactionId for the payment code.
5Your server or appPOST /purchaseTxPay, exactly as in Approach A step 3.
6Your serverPOST /transactionState/{transactionId}/v2Poll for the final outcome, exactly as in Approach A step 4.

Before you start

Scan to Pay sets up the following with you:

ItemWhat is needed
Hosted menu enabledScan 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 linkOne 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 menuThe 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:

SettingFormatUsed for
headerColour, #RRGGBBTop bar background
onHeaderColour, #RRGGBBTitle and back arrow on the top bar
primaryColour, #RRGGBBButtons, list chevrons, focused input border
onPrimaryColour, #RRGGBBText on buttons
backgroundColour, #RRGGBBPage background
surfaceColour, #RRGGBBCards, list rows and input fields
textColour, #RRGGBBMain text
mutedTextColour, #RRGGBBSubtitles, hints and icons
fontsystem, sans-serif, serif or roundedFont family
radiusWhole number, 0 to 24Corner 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.

FieldRequiredFormatDescription
msisdnOne of msisdn / clientIdentifier^[1-9]\d{8,16}Customer's mobile number in international format, as on your other calls.
clientIdentifierOne of msisdn / clientIdentifierUp to 100 charactersYour customer identifier, if you use one instead of msisdn.
menuGroupOptionalACCOUNTS, PREPAID or DONATIONSShow only this group (for example only airtime and data). Leave it out to show every group that has items.
stateOptional, 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
}
  • url is the page to open in step 2. Always use it as returned; do not build or change it.
  • expiresIn is the number of seconds within which the url must be opened (120 by default).
  • The response is sent with Cache-Control: no-store.

Errors:

Trigger conditionHTTPmessage
Neither clientIdentifier nor msisdn supplied400Validation 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 wallet400Validation error: Web menu not available for this wallet
No billers configured for your wallet, or none in the requested menuGroup400Validation error: No Menu Configured
Customer exists but is not in ACTIVE state400Client Suspended
Not authenticated401Security-layer default
Your wallet is not enabled for the hosted menu403You are not authorized to access this resource
🚧

The url is a one-time secret

It works once, and only within expiresIn seconds. 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 returns true, goBack(); close the webview only when it returns false. The page then steps back one screen at a time, like its own back arrow, and on its first screen canGoBack() is false. 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 https biller logo images.

What the customer may see:

SituationPage showsWhat your app does
Biller rejects the details (for example an invalid account)The biller's message; the customer can correct and retryNothing
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:

  1. Intercept the navigation to your scheme in the webview's URL-loading callback, and not let the webview try to load it.
  2. Read the payment code from the position of {paymentReference} in your link, and read state.
  3. Check state equals the value you sent in step 1 for this customer. If it does not, ignore the result.
  4. Close the webview.
  5. 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 menuGroup when 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 / clientIdentifier in steps 1 and 4.

What's next


Did this page help you?