Purchase flow

Step-by-step Remote API call sequence — from QR scan to confirmed transaction — with sample requests and responses.

This is the call-by-call walk-through of a wallet purchase through the Scan to Pay Remote API. For the protocol overview (endpoints, auth, base URLs), see Remote API overview.


The standard flow

   Wallet app          Wallet backend         Scan to Pay              Acquirer
   ──────────          ──────────────         ───────────              ────────
        │                       │                    │                        │
   scan QR / paste code         │                    │                        │
        ├──────────────────────►│                    │                        │
        │                       │                    │                        │
        │                       │  /generateTransactionId                     │
        │                       ├───────────────────►│                        │
        │                       │◄───────────────────┤                        │
        │                       │   merchant, amount, transactionId,          │
        │                       │   acceptedCards, auth method                │
        │                       │                    │                        │
        │  show merchant + amount, prompt for PIN     │                       │
        │◄──────────────────────┤                    │                        │
        │                       │                    │                        │
   PIN entered → encrypted by PinSecLib              │                        │
        ├──────────────────────►│                    │                        │
        │                       │                    │                        │
        │                       │  /purchaseTx (with pinData)                 │
        │                       ├───────────────────►│                        │
        │                       │                    ├──── auth ─────────────►│
        │                       │                    │◄──── auth response ────┤
        │                       │◄───────────────────┤                        │
        │                       │   status: PENDING                           │
        │                       │                    │                        │
        │                       │  /transactionState/{id}  (poll)             │
        │                       ├───────────────────►│                        │
        │                       │◄───────────────────┤                        │
        │                       │   status: SUCCESS, authCode, RRN            │
        │                       │                    │                        │
        │  payment confirmed    │                    │                        │
        │◄──────────────────────┤                    │                        │

Three calls minimum: lookup, purchase, state. Optional fourth — 3DS lookup — slots in between for cards that require Secure Code instead of PIN.


Step 1 — Lookup the QR

The first call resolves the scanned QR / code into a merchant, amount, and accepted payment methods. The response also tells you whether the merchant requires AMT (PIN) or Secure Code (3DS) authentication — your wallet UI branches from there.

Request:

curl -X POST https://qa.scantopay.io/pluto/remote/v4/generateTransactionId \
  -u 'wallet_username:wallet_password' \
  -H 'Content-Type: application/json' \
  -d '{
    "msisdn": "27832006283",
    "value": "8656849931",
    "deviceData": {
      "os": "Android",
      "osVersion": "14",
      "appVersion": "5.2.1",
      "device": "Samsung Galaxy S24",
      "latitude": "-26.13382683823002",
      "longitude": "28.06927071511155",
      "clientIP": "196.1.2.3"
    }
  }'

Response:

{
  "status": "SUCCESS",
  "merchantName": "ABC Test Store",
  "value": "6400594798",
  "transactionId": 469623,
  "data": {
    "amount": 5.75,
    "currency": "R",
    "currencyCode": "710",
    "shortDescription": "Please Pay This",
    "partialPayment": false,
    "tipRequired": false,
    "acceptedCards": "master, visa, maestro",
    "acceptedPaymentMethods": "AMT, SECURE_CODE",
    "poi": "12"
  }
}

What to do with the response:

Response fieldWallet behaviour
merchantName, data.amount, data.shortDescriptionShow on the confirmation screen
data.acceptedCardsFilter the vault — only offer cards the merchant accepts
data.acceptedPaymentMethodsAMT → prompt for PIN. SECURE_CODE → run a 3DS challenge. Both → pick the customer's default.
data.partialPayment: trueShow an amount input — the customer can pay less than the QR amount
data.tipRequired: trueShow a tip input (use data.tipFixed or data.tipPercentage as the suggested value)
transactionIdStore. You need it for the next call.
value (echoed)Use this on the purchase call — it may differ from the value you scanned

Step 1a — Lookup that needs extra input

Some QRs (typically bill payments) require additional inputs from the customer before the lookup can complete. The response signals this with status: "NEED_INPUT":

{
  "status": "NEED_INPUT",
  "merchantName": "BillerABC",
  "value": "https://payat.io/qr/11669",
  "inputsRequired": [
    {
      "ref": "accountNumber",
      "title": "Please enter account number",
      "type": "NUMERIC",
      "regex": "\\d",
      "regexErrorMessage": "Please enter digits only"
    }
  ]
}

Render the prompts in your wallet UI, collect the values, then call generateTransactionId again with the inputs filled in:

curl -X POST https://qa.scantopay.io/pluto/remote/v4/generateTransactionId \
  -u 'wallet_username:wallet_password' \
  -H 'Content-Type: application/json' \
  -d '{
    "msisdn": "27832006283",
    "value": "https://payat.io/qr/11669",
    "inputs": [
      { "ref": "accountNumber", "value": "272257910281" }
    ]
  }'

The second call returns status: "SUCCESS" with the resolved amount and you proceed normally.


Step 2 — Purchase (AMT / PIN authentication)

For most cards in South Africa, the customer authenticates with a PIN. The PIN must be encrypted client-side using the Scan to Pay PinSecLib library — never sent in plain text.

Request:

curl -X POST https://qa.scantopay.io/pluto/remote/v4/purchaseTx \
  -u 'wallet_username:wallet_password' \
  -H 'Content-Type: application/json' \
  -d '{
    "msisdn": "27832006283",
    "value": "6400594798",
    "transactionId": 469623,
    "cardNumber": "5123451234567897",
    "expiryDate": "1228",
    "cardHolderName": "J Smith",
    "dateOfBirth": "19800101",
    "amount": 5.75,
    "pinData": {
      "encryptedData": "jtB7deO95L/v5sQDEJNEj4BPjqRE645Rb1YA0tUz3MoQX4N+DVTUSUqK..."
    }
  }'

Response:

{
  "status": "PENDING",
  "transactionId": 469623,
  "date": "2026-05-14 11:42:22"
}

PENDING means the request is accepted and the acquirer is processing it. The transaction's final state arrives via /transactionState polling or your webhook.


Step 2 — Purchase (3D Secure)

For BINs that require 3D Secure rather than PIN, the flow has an extra step: the 3DS lookup to get the ACS payload your wallet's WebView must POST.

Request — /secureCodeLookup:

curl -X POST https://qa.scantopay.io/pluto/remote/v4/secureCodeLookup \
  -u 'wallet_username:wallet_password' \
  -H 'Content-Type: application/json' \
  -d '{
    "cardNumber": "5123451234567897",
    "expiryDate": "1228",
    "transactionId": 469623,
    "returnUrl": "https://your-wallet.example.com/3ds-return",
    "amount": 5.75
  }'

Response:

{
  "secureCodeId": 11248,
  "transactionId": 469623,
  "webViewData": "<HTML><head><meta name='viewport' content='width=device-width'></head><BODY onload='document.frmLaunch.submit();'>... <FORM name='frmLaunch' method='POST' action='https://acstest.bankserv.co.za/V3DSStart'><input type=hidden name='PaReq' value='eJxVUdluwjAQ...'><input type=hidden name='TermUrl' value='https://your-wallet.example.com/3ds-return'></FORM></BODY></HTML>"
}

Render webViewData in a WebView. The issuer's ACS will challenge the customer (e.g. an OTP), then redirect to your returnUrl with the PaRes payload. Capture it, then submit the purchase:

curl -X POST https://qa.scantopay.io/pluto/remote/v4/purchaseTx \
  -u 'wallet_username:wallet_password' \
  -H 'Content-Type: application/json' \
  -d '{
    "msisdn": "27832006283",
    "value": "6400594798",
    "transactionId": 469623,
    "cardNumber": "5123451234567897",
    "expiryDate": "1228",
    "amount": 5.75,
    "secureCodeBasic": {
      "secureCodeId": 11248,
      "paRes": "eNrFWNmSo0iyfecrynoemW52hNqUaRasAgQSiP2NTYBYxSKQvv6SmbXk1..."
    }
  }'

If you have your own 3DS infrastructure and want to pass the raw CAVV / XID / ECI flags rather than use Scan to Pay's hosted ACS, send them in secureCodeData instead:

"secureCodeData": {
  "cavv": "jM0YaCE08yfhCREAABFNBAcAAAA=",
  "xid": "aTRFelljS0NjcjFwRHhvNEo0WjA=",
  "eciFlag": "02",
  "errorNo": "0",
  "paresStatus": "01",
  "signatureVerification": "Y"
}

Step 3 — Poll for the result

After /purchaseTx returns PENDING, poll /transactionState/{transactionId} until you see a terminal state.

curl -X POST https://qa.scantopay.io/pluto/remote/v4/transactionState/469623 \
  -u 'wallet_username:wallet_password' \
  -H 'Content-Type: application/json'

Response — success:

{
  "status": "SUCCESS",
  "transactionId": 469623,
  "reference": "PAYAT0000039172",
  "amount": 5.75,
  "currencyCode": "ZAR",
  "code": "6400594798",
  "clientMsisdn": "27832006283",
  "retrievalReferenceNumber": 282352,
  "authCode": "A1B2C3",
  "bankResponse": "00",
  "date": "2026-05-14 11:42:25",
  "merchantName": "ABC Test Store",
  "source": "ACQUIRER_NAME"
}

Cadence: Poll every 1-2 seconds for the first 30 seconds, then back off. Most transactions settle in 3-5 seconds; the longest legitimate OFF_TO_BANK window is ~45 seconds before the bank's auth timeout kicks in.

For the full set of statuses you might see (OFF_TO_BANK, END_BANK_NON_00, END_REVERSED, END_INSUFFICIENT_FUNDS_OR_OVER_CREDIT_LIMIT, and 80+ more), see Transaction states.


BIN Lookup (optional — drives form fields)

Before showing the card-entry form for an unknown card, call /binLookup to learn what fields the BIN's issuer requires:

curl -X POST https://qa.scantopay.io/pluto/remote/v4/binLookup \
  -u 'wallet_username:wallet_password' \
  -H 'Content-Type: application/json' \
  -d '{ "bin": "512345" }'

Response:

{
  "authMethod": "AMT",
  "cvvNeeded": true,
  "expiryNeeded": true,
  "accNeeded": false,
  "dobNeeded": false,
  "cardHolderNameNeeded": true,
  "displayName": "Bank ABC Debit Card"
}

Use this to hide unneeded fields (e.g. don't show "Date of birth" if dobNeeded: false) and to know whether to route the card to PIN or 3DS.


Transaction history (UI display)

For showing the customer's recent payments inside the wallet:

curl -X POST https://qa.scantopay.io/pluto/remote/v4/transactionHistory \
  -u 'wallet_username:wallet_password' \
  -H 'Content-Type: application/json' \
  -d '{ "msisdn": "27832006283" }'

Returns the last 10 successful payments for that MSISDN. Use this for the "recent payments" list — it's not designed to replace your own ledger.


Optional: webhook delivery instead of polling

Pass a webhook URL on /purchaseTx and Scan to Pay will POST the final state to your endpoint:

{
  "msisdn": "27832006283",
  "value": "6400594798",
  "transactionId": 469623,
  ...
  "webhook": "https://your-wallet-backend.example.com/scan-to-pay-webhook"
}

Webhook payload, signing, decryption, and the 45-second acknowledgement rule are documented under Webhooks and Signing and verifying webhooks.

For wallet UIs, polling is usually preferred — the customer is staring at the screen and a webhook adds backend complexity. Use the webhook when your wallet has a server-side balance ledger that must be reconciled even if the customer kills the app.


What's next