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 parameter | Always present | Description |
|---|---|---|
status | ✓ | One of the values below |
transactionId | When status is SUCCESS | Scan to Pay's internal transaction ID. Use this to look up the transaction via webhook payload or getTransactionState. |
code | When relevant | The 10-digit code used for this transaction |
errorMessage | When status is FAILED and a reason is available | Human-readable failure reason |
Status values
| Status | Meaning | Recommended action |
|---|---|---|
SUCCESS | The 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. |
CANCELED | The customer cancelled in the wallet (backed out before authorising). | Treat as abandoned. Allow the customer to retry. |
FAILED | Something 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:
- Initiating a real payment for a small amount.
- Recording the success URL.
- Cancelling at the bank.
- 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
- Wallet returns to your app with
status=SUCCESS. - Your UI transitions to "Confirming your payment…" — don't release goods yet.
- Your app polls your backend for the order's authoritative status.
- Your backend has already received (or shortly will receive) the encrypted webhook from Scan to Pay with the real outcome. See Webhooks.
- Once your backend has decrypted and validated the webhook, return the result to your app.
- 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
- Implementation on Android → App-to-app on Android
- Implementation on iOS → App-to-app on iOS
- Receive the authoritative outcome via webhook → Webhooks
- Decrypt the webhook payload → Signing and verifying webhooks
- Map status to customer-friendly copy → ISO response codes
- Refund or reverse if something goes wrong → Refunds and reversals
Updated 4 days ago
