Adapting your Android payment app for Web Payments

The Payment Request API gives the web a native, browser-based interface for collecting payment information, and it can hand off to platform-specific payment apps. For an Android payment app, Web Payments provides deeper integration than plain Intents: the app opens as a modal within the merchant's page, works alongside your existing app to leverage your user base, checks the payment app's signature to prevent sideloading, supports multiple payment methods, and can even reach hardware-backed methods like the device's secure chip.

Implementation involves four steps: make your app discoverable to merchants, report instrument availability, process the payment, and verify the caller's certificate. A complete working example is in the android-web-payment demo.

Discoverability and instrument checks

First, point merchants to your app. Set the related_applications property in your web app manifest as described in Setting up a payment method. Merchants invoke the Payment Request API with the payment method identifier you support. If you use your own unique identifier, publish a payment method manifest so browsers can find your app.

Merchants can then call hasEnrolledInstrument() to check whether a customer is ready to pay. Your app answers through an IS_READY_TO_PAY Android service. This step is optional — no handler means the browser assumes payments are always possible.

Declaring the service

In AndroidManifest.xml, register a service with an intent filter for the action org.chromium.intent.action.IS_READY_TO_PAY.

<service
  android:name=".SampleIsReadyToPayService"
  android:exported="true">
  <intent-filter>
    <action android:name="org.chromium.intent.action.IS_READY_TO_PAY" />
  </intent-filter>
</service>

The service API is defined via AIDL. Create two interface files:

org/chromium/IsReadyToPayServiceCallback.aidl

package org.chromium;

interface IsReadyToPayServiceCallback {
    oneway void handleIsReadyToPay(boolean isReadyToPay);
}

org/chromium/IsReadyToPayService.aidl

package org.chromium;

import org.chromium.IsReadyToPayServiceCallback;

interface IsReadyToPayService {
    oneway void isReadyToPay(IsReadyToPayServiceCallback callback, in Bundle parameters);
}

Implementing the service

A minimal Kotlin implementation:

class SampleIsReadyToPayService : Service() {
    private val binder = object : IsReadyToPayService.Stub() {
        override fun isReadyToPay(callback: IsReadyToPayServiceCallback?, parameters: Bundle?) {
            callback?.handleIsReadyToPay(true)
        }
    }

    override fun onBind(intent: Intent?): IBinder? {
        return binder
    }
}

The Java equivalent:

import org.chromium.IsReadyToPayService;

public class SampleIsReadyToPayService extends Service {
    private final IsReadyToPayService.Stub mBinder =
        new IsReadyToPayService.Stub() {
            @Override
            public void isReadyToPay(IsReadyToPayServiceCallback callback, Bundle parameters) {
                if (callback != null) {
                    callback.handleIsReadyToPay(true);
                }
            }
        };

    @Override
    public IBinder onBind(Intent intent) {
        return mBinder;
    }
}

Send the result back through the callback's handleIsReadyToPay(Boolean) method — in Kotlin:

callback?.handleIsReadyToPay(true)

or in Java:

if (callback != null) {
    callback.handleIsReadyToPay(true);
}

Security checks and parameters

Because the Android OS may cache and reuse service connections, verify the caller with Binder.getCallingUid() inside isReadyToPay() — not in onBind(), which won't fire on every call.

override fun isReadyToPay(callback: IsReadyToPayServiceCallback?, parameters: Bundle?) {
    try {
        val untrustedPackageName = parameters?.getString("packageName")
        val actualPackageNames = packageManager.getPackagesForUid(Binder.getCallingUid())
        // ...

The Java variant:

@Override
public void isReadyToPay(IsReadyToPayServiceCallback callback, Bundle parameters) {
    try {
        String untrustedPackageName = parameters != null
                ? parameters.getString("packageName")
                : null;
        String[] actualPackageNames = packageManager.getPackagesForUid(Binder.getCallingUid());
        // ...

Never trust raw IPC input. Different Android versions and forks can behave unexpectedly, so always null-check the input parameters. Also, packageManager.getPackagesForUid() can return multiple package names in rare cases — handle that. For signature verification of the calling package, see Verify the caller's signing certificate.

The service receives an optional parameters Bundle, added in Chrome 139, which must also be null-checked. It contains:

  • packageName
  • methodNames
  • methodData
  • topLevelOrigin
  • paymentRequestOrigin
  • topLevelCertificateChain

packageName arrived in Chrome 138 and must be validated against Binder.getCallingUid(), since the caller fully controls the bundle while the Android OS controls the UID. Note that topLevelCertificateChain is null in WebView and on non-HTTPS origins like http://localhost used for local testing.

Accepting a payment

The merchant triggers the payment app with show(), which launches the app via an Android PAY intent carrying transaction data in its parameters. Your app then returns methodName and details. The details value is opaque to the browser—it deserializes the JSON string into a JavaScript dictionary for the merchant without performing any additional validation or modification.

Declaring payment methods

Your activity with the PAY intent filter must register a <meta-data> tag that points to the default payment method identifier. To support several payment methods, use a <meta-data> tag backed by a <string-array> resource:

<activity
  android:name=".PaymentActivity"
  android:theme="@style/Theme.SamplePay.Dialog">
  <intent-filter>
    <action android:name="org.chromium.intent.action.PAY" />
  </intent-filter>

  <meta-data
    android:name="org.chromium.default_payment_method_name"
    android:value="https://bobbucks.dev/pay" />
  <meta-data
    android:name="org.chromium.payment_method_names"
    android:resource="@array/chromium_payment_method_names" />
</activity>

Every string in the android:resource list must be a valid, absolute HTTPS URL:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="chromium_payment_method_names">
        <item>https://alicepay.com/put/optional/path/here</item>
        <item>https://charliepay.com/put/optional/path/here</item>
    </string-array>
</resources>

Intent extras

The following extras are supplied to the activity:

  • methodNames
  • methodData
  • merchantName
  • topLevelOrigin
  • topLevelCertificateChain
  • paymentRequestOrigin
  • total
  • modifiers
  • paymentRequestId
  • paymentOptions
  • shippingOptions
val extras: Bundle? = getIntent()?.extras

The methodNames list keys the methodData dictionary; the payment app must support each named method. methodData maps each of those names to its corresponding methodData object:

val methodNames: List<String>? = extras.getStringArrayList("methodNames")
val methodData: Bundle? = extras.getBundle("methodData")

merchantName holds the contents of the checkout page's <title> tag (the browser's top-level browsing context):

val merchantName: String? = extras.getString("merchantName")

topLevelOrigin is the merchant's scheme-less origin, so https://mystore.com/checkout arrives as mystore.com:

val topLevelOrigin: String? = extras.getString("topLevelOrigin")

For WebView, localhost, or file:// contexts, topLevelCertificateChain is null. Otherwise, each Parcelable is a Bundle with a certificate key holding a byte array:

val topLevelCertificateChain: Array<Parcelable>? =
        extras.getParcelableArray("topLevelCertificateChain")
val list: List<ByteArray>? = topLevelCertificateChain?.mapNotNull { p ->
    (p as Bundle).getByteArray("certificate")
}

When the payment flow is initiated from an iframe, paymentRequestOrigin is the scheme-less origin of that iframe context; for top-level invocations it equals topLevelOrigin:

val paymentRequestOrigin: String? = extras.getString("paymentRequestOrigin")

total is the JSON string for the transaction's total amount:

val total: String? = extras.getString("total")

For example:

{"currency":"USD","value":"25.00"}

modifiers is JSON.stringify(details.modifiers), but with only supportedMethods, data, and total included. Use paymentRequestId (the JavaScript PaymentRequest.id) to let push-payment apps track transaction state out of band:

val paymentRequestId: String? = extras.getString("paymentRequestId")

Returning a result

The activity sends its response via setResult with RESULT_OK, supplying two required extras:

  • methodName: the method used to complete the transaction.
  • details: a JSON string with the data the merchant needs. For a successful payment, JSON.parse(details) must succeed; you can send "{}" when nothing needs to be returned.
setResult(Activity.RESULT_OK, Intent().apply {
    putExtra("methodName", "https://bobbucks.dev/pay")
    putExtra("details", "{\"token\": \"put-some-data-here\"}")
})
finish()

If the user cancels in the payment app, pass RESULT_CANCELED. The merchant's request.show() then rejects with an AbortError signalling user cancellation:

setResult(Activity.RESULT_CANCELED)
finish()

Starting with Chrome 149, these additional result codes are supported:

Activity.RESULT_CANCELED // 0 (0x00000000)
Activity.RESULT_OK // -1 (0xffffffff)
const val INTERNAL_PAYMENT_APP_ERROR = Activity.RESULT_FIRST_USER // 1 (0x00000001)

Use Activity.RESULT_FIRST_USER as the result code to report an internal failure. This produces INTERNAL_PAYMENT_APP_ERROR and causes request.show() to reject with OperationError on the merchant site. The distinction lets merchants tell apart user cancellations (AbortError) and genuine app errors (OperationError), enabling better flows:

Activity.RESULT_CANCELED // 0 (0x00000000)
Activity.RESULT_OK // -1 (0xffffffff)
static final int INTERNAL_PAYMENT_APP_ERROR = Activity.RESULT_FIRST_USER; // 1 (0x00000001)

When RESULT_OK comes back, Chrome validates that the methodName and details extras are non-empty. If not, request.show() returns a rejected promise with one of these messages:

'Payment app returned invalid response. Missing field "details".'
'Payment app returned invalid response. Missing field "methodName".'

Verifying the caller

Inside the activity you can check the caller with getCallingPackage():

val caller: String? = callingPackage

That package name alone isn't enough—you must also confirm the signing certificate matches the expected browser. For API level 28+, prefer PackageManager.hasSigningCertificate() because it correctly handles certificate rotation for single-signature browsers like Chrome:

val packageName: String = … // The caller's package name
val certificate: ByteArray = … // The correct signing certificate
val verified = packageManager.hasSigningCertificate(
        callingPackage,
        certificate,
        PackageManager.CERT_INPUT_SHA256
)

For API levels 27 and earlier, or browsers with multiple signing certificates, fall back to PackageManager.GET_SIGNATURES:

val packageName: String = … // The caller's package name
val expected: Set<String> = … // The correct set of signing certificates

val packageInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
val sha256 = MessageDigest.getInstance("SHA-256")
val actual = packageInfo.signatures.map {
    SerializeByteArrayToString(sha256.digest(it.toByteArray()))
}
val verified = actual.equals(expected)

Note that apps with multiple signing certificates cannot rotate them.

Debugging

To inspect error or informational log messages, run:

adb logcat | grep -i pay