Handling the response

What the wallet sends back to your app, how to parse it, and the trust model for treating it as a payment result.

When the customer finishes in the Scan to Pay wallet, it returns to your app by opening your registered URL scheme with status information in the query string. This page documents every value that can come back, how to parse it, and what to do with it.

The platform-specific mechanics of receiving the return are on App-to-app on Android and App-to-app on iOS. This page is about what to do once you've got the URL.


Return URL format

your.app.scheme://your.host?status={STATUS}&transactionId={ID}
Query parameterAlways presentDescription
statusOne of the values below
transactionIdWhen status is SUCCESSScan to Pay's internal transaction ID. Use this to look up the transaction via webhook payload or getTransactionState.
codeWhen relevantThe 10-digit code used for this transaction
errorMessageWhen status is FAILED and a reason is availableHuman-readable failure reason

Status values

StatusMeaningRecommended action
SUCCESSThe customer finished the flow and the wallet reported success. This is not yet proof of payment.Move UI to "Confirming…" and verify via backend before treating the order as paid.
CANCELEDThe customer cancelled in the wallet (backed out before authorising).Treat as abandoned. Allow the customer to retry.
FAILEDSomething went wrong in the wallet flow itself — declined card, validation error, network problem.Show a friendly error, let the customer try with a different funding source.

The trust model — read this once, code accordingly

⚠️

The return URL is a UX trigger, not a payment confirmation.

Any app on the customer's device can construct and call your URL scheme. Nothing in the URL is signed or encrypted. If you treat the return URL as proof of payment, an attacker can defraud you trivially by:

  1. Initiating a real payment for a small amount.
  2. Recording the success URL.
  3. Cancelling at the bank.
  4. Manually invoking your.app.scheme://?status=SUCCESS&transactionId={stolen-id} on their device.

To you, this looks identical to a successful payment.

The correct pattern

  1. Wallet returns to your app with status=SUCCESS.
  2. Your UI transitions to "Confirming your payment…" — don't release goods yet.
  3. Your app polls your backend for the order's authoritative status.
  4. Your backend has already received (or shortly will receive) the encrypted webhook from Scan to Pay with the real outcome. See Webhooks.
  5. Once your backend has decrypted and validated the webhook, return the result to your app.
  6. Your app shows the success page only then.

The webhook is signed via merchant-key encryption and is impossible to forge without the key. That's the proof.


Parsing the URL

Android (Kotlin)

val data = intent.data ?: return
val status        = data.getQueryParameter("status")
val transactionId = data.getQueryParameter("transactionId")

when (status) {
    "SUCCESS"  -> verifyWithBackend(transactionId)
    "CANCELED" -> showAbandonedScreen()
    "FAILED"   -> showFailureScreen(data.getQueryParameter("errorMessage"))
    else       -> showGenericErrorScreen()
}

iOS (Swift)

guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
      let queryItems = components.queryItems else { return }

let status        = queryItems.first { $0.name == "status" }?.value
let transactionId = queryItems.first { $0.name == "transactionId" }?.value
let errorMessage  = queryItems.first { $0.name == "errorMessage" }?.value

switch status {
case "SUCCESS":   verifyWithBackend(transactionId)
case "CANCELED":  showAbandonedScreen()
case "FAILED":    showFailureScreen(errorMessage)
default:          showGenericErrorScreen()
}

Backend verification

When your app receives SUCCESS, it should poll your backend for the order's status. Your backend in turn relies on the webhook:

App         Your backend        Scan to Pay
 │                │                  │
 │  status req    │                  │
 ├───────────────►│                  │
 │                │                  │
 │  pending       │  (webhook        │
 │◄───────────────┤   not yet        │
 │                │   received)      │
 │                │                  │
 │                │   webhook (CB)   │
 │                │◄─────────────────┤
 │                │   ack 200        │
 │                ├─────────────────►│
 │                │                  │
 │  status req    │                  │
 ├───────────────►│                  │
 │  succeeded     │                  │
 │◄───────────────┤                  │
 │                │                  │
 │  show success  │                  │

Most webhooks arrive within 2–5 seconds of the customer authorising. Poll your backend every 1–2 seconds for up to 30 seconds. If you don't get a definitive answer in that window, fall back to a "we'll email you when it's confirmed" UX rather than guessing.

For the full webhook lifecycle, see Webhooks.


Handling slow or missed returns

The customer's phone might not return to your app — battery dies mid-flow, they hit the home button, the wallet app crashes. In any of these cases:

  • Your backend still receives the webhook (the wallet completes the transaction independently of returning).
  • Your app gets stuck on "Awaiting payment…"

Mitigations:

  • Server-push to your app (WebSocket, Apple Push, Firebase Cloud Messaging) the moment your backend gets the webhook. This way the customer's order updates even if they never re-open your app.
  • Reconcile on next app launch — when the customer eventually opens your app, query your backend for any in-flight orders and update the UI.
  • Timeout the UI after ~2 minutes of waiting and direct the customer to check their wallet or contact support.

What's next