Learn how to adapt your Android payment app to work with Web Payments and provide a better user experience for customers.
Published: May 5, 2020, Last updated: May 27, 2025
The Payment Request API brings to the web a built-in browser-based interface that allows users to enter required payment information easier than ever before. The API can also invoke platform-specific payment apps.
Compared to using just Android Intents, Web Payments allow better integration with the browser, security, and user experience:
- The payment app is launched as a modal, in context of the merchant website.
- Implementation is supplemental to your existing payment app, enabling you to take advantage of your user base.
- The payment app's signature is checked to prevent sideloading.
- Payment apps can support multiple payment methods.
- Any payment method, such as cryptocurrency, bank transfers, and more, can be integrated. Payment apps on Android devices can even integrate methods that require access to the hardware chip on the device.
It takes four steps to implement Web Payments in an Android payment app:
- Let merchants discover your payment app.
- Let a merchant know if a customer has an enrolled instrument (such as credit card) that is ready to pay.
- Let a customer make payment.
- Verify the caller's signing certificate.
To see Web Payments in action, check out the android-web-payment demo.
Step 1: Let merchants discover your payment app
Set the related_applications
property in the web app manifest according to the
instructions in Setting up a payment method.
In order for a merchant to use your payment app, they need to use the Payment Request API and specify the payment method you support using the payment method identifier.
If you have a payment method identifier that is unique to your payment app, you can set up your own payment method manifest so browsers can discover your app.
Step 2: Let a merchant know if a customer has an enrolled instrument that is ready to pay
The merchant can call hasEnrolledInstrument()
to query whether the customer
is able to make a payment. You can
implement IS_READY_TO_PAY
as an Android service to answer this query.
AndroidManifest.xml
Declare your service with an intent filter with 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 IS_READY_TO_PAY
service is optional. If there's no such intent handler in
the payment app, then the web browser assumes that the app can always make
payments.
AIDL
The API for the IS_READY_TO_PAY
service is defined in AIDL. Create two AIDL
files with the following content:
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 IsReadyToPayService
The simplest implementation of IsReadyToPayService
is shown in the following
example:
Kotlin
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
}
}
Java
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;
}
}
Response
The service can send its response using the handleIsReadyToPay(Boolean)
method.
Kotlin
callback?.handleIsReadyToPay(true)
Java
if (callback != null) {
callback.handleIsReadyToPay(true);
}
Permission
You can use Binder.getCallingUid()
to check who the caller is. Note that you
have to do this in the isReadyToPay
method, not in the onBind
method,
because Android OS can cache and reuse the service connection, which does not
trigger the onBind()
method.
Kotlin
override fun isReadyToPay(callback: IsReadyToPayServiceCallback?, parameters: Bundle?) {
try {
val untrustedPackageName = parameters?.getString("packageName")
val actualPackageNames = packageManager.getPackagesForUid(Binder.getCallingUid())
// ...
Java
@Override
public void isReadyToPay(IsReadyToPayServiceCallback callback, Bundle parameters) {
try {
String untrustedPackageName = parameters != null
? parameters.getString("packageName")
: null;
String[] actualPackageNames = packageManager.getPackagesForUid(Binder.getCallingUid());
// ...
Always check input parameters for null
when receiving
Inter-Process Communication (IPC) calls. This is
particularly important because different versions or
forks of the Android OS can behave in unexpected ways
and lead to errors if not handled.
While packageManager.getPackagesForUid()
typically
returns a single element, your code must handle the
uncommon scenario where a caller utilizes multiple
package names. This ensures your application remains
robust.
See Verify the caller's signing certificate about how to verify that the calling package has the right signature.
Parameters
The parameters
Bundle was added in Chrome 139. It should always be checked
against null
.
The following parameters are passed to the service in the parameters
Bundle:
packageName
methodNames
methodData
topLevelOrigin
paymentRequestOrigin
topLevelCertificateChain
The packageName
was added in Chrome 138. You must verify
this parameter against Binder.getCallingUid()
before
using its value. This verification is essential because
the parameters
bundle is under full control of the caller,
while Binder.getCallingUid()
is controlled by the Android OS.
The topLevelCertificateChain
is null
in WebView and on non-https websites
that are typically used for local testing, such as http://localhost
.
Step 3: Let a customer make payment
The merchant calls show()
to launch the payment
app
so the customer can make a payment. The payment app is invoked using an Android
intent PAY
with transaction information in the intent parameters.
The payment app responds with methodName
and details
, which are payment app
specific and are opaque to the browser. The browser converts the details
string into a JavaScript dictionary for the merchant using JSON string
deserialization, but does not enforce any validity beyond that. The browser does
not modify the details
; that parameter's value is passed directly to the
merchant.
AndroidManifest.xml
The activity with the PAY
intent filter should have a <meta-data>
tag that
identifies the default payment method identifier for the
app.
To support multiple payment methods, add a <meta-data>
tag with 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>
The android:resource
must be a list of strings, each of which must be a valid,
absolute URL with an HTTPS scheme as shown here.
<?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>
Parameters
The following parameters are passed to the activity as Intent
extras:
methodNames
methodData
merchantName
topLevelOrigin
topLevelCertificateChain
paymentRequestOrigin
total
modifiers
paymentRequestId
paymentOptions
shippingOptions
Kotlin
val extras: Bundle? = getIntent()?.extras
Java
Bundle extras = getIntent() != null ? getIntent().getExtras() : null;
methodNames
The names of the methods being used. The elements are the keys in the
methodData
dictionary. These are the methods that the payment app supports.
Kotlin
val methodNames: List<String>? = extras.getStringArrayList("methodNames")
Java
List<String> methodNames = extras.getStringArrayList("methodNames");
methodData
A mapping from each of the methodNames
to the
methodData
.
Kotlin
val methodData: Bundle? = extras.getBundle("methodData")
Java
Bundle methodData = extras.getBundle("methodData");
merchantName
The contents of the <title>
HTML tag of the merchant's checkout page (the
browser's top-level browsing context).
Kotlin
val merchantName: String? = extras.getString("merchantName")
Java
String merchantName = extras.getString("merchantName");
topLevelOrigin
The merchant's origin without the scheme (The scheme-less origin of the
top-level browsing context). For example, https://mystore.com/checkout
is
passed as mystore.com
.
Kotlin
val topLevelOrigin: String? = extras.getString("topLevelOrigin")
Java
String topLevelOrigin = extras.getString("topLevelOrigin");
topLevelCertificateChain
The merchant's certificate chain (the certificate chain of the top-level
browsing context). The value is null
for WebView, localhost, or a file on disk.
Each Parcelable
is a Bundle with a certificate
key and a byte array value.
Kotlin
val topLevelCertificateChain: Array<Parcelable>? =
extras.getParcelableArray("topLevelCertificateChain")
val list: List<ByteArray>? = topLevelCertificateChain?.mapNotNull { p ->
(p as Bundle).getByteArray("certificate")
}
Java
Parcelable[] topLevelCertificateChain =
extras.getParcelableArray("topLevelCertificateChain");
if (topLevelCertificateChain != null) {
for (Parcelable p : topLevelCertificateChain) {
if (p != null && p instanceof Bundle) {
((Bundle) p).getByteArray("certificate");
}
}
}
paymentRequestOrigin
The scheme-less origin of the iframe browsing context that invoked the new
PaymentRequest(methodData, details, options)
constructor in JavaScript. If the
constructor was invoked from the top-level context, then the value of this
parameter equals the value of topLevelOrigin
parameter.
Kotlin
val paymentRequestOrigin: String? = extras.getString("paymentRequestOrigin")
Java
String paymentRequestOrigin = extras.getString("paymentRequestOrigin");
total
The JSON string representing the total amount of the transaction.
Kotlin
val total: String? = extras.getString("total")
Java
String total = extras.getString("total");
Here's an example content of the string:
{"currency":"USD","value":"25.00"}
modifiers
The output of JSON.stringify(details.modifiers)
, where details.modifiers
contain only supportedMethods
, data
, and total
.
paymentRequestId
The PaymentRequest.id
field that "push-payment" apps should associate with the
transaction state. Merchant websites will use this field to query the
"push-payment" apps for the state of transaction out of band.
Kotlin
val paymentRequestId: String? = extras.getString("paymentRequestId")
Java
String paymentRequestId = extras.getString("paymentRequestId");
Response
The activity can send its response back through setResult
with RESULT_OK
.
Kotlin
setResult(Activity.RESULT_OK, Intent().apply {
putExtra("methodName", "https://bobbucks.dev/pay")
putExtra("details", "{\"token\": \"put-some-data-here\"}")
})
finish()
Java
Intent result = new Intent();
Bundle extras = new Bundle();
extras.putString("methodName", "https://bobbucks.dev/pay");
extras.putString("instrumentDetails", "{\"token\": \"put-some-data-here\"}");
result.putExtras(extras);
setResult(Activity.RESULT_OK, result);
finish();
You must specify two parameters as Intent extras:
methodName
: The name of the method being used.details
: JSON string containing information necessary for the merchant to complete the transaction. If success istrue
, thendetails
must be constructed in such a way thatJSON.parse(details)
will succeed. If there is no data that needs to be returned, then this string can be"{}"
, which the merchant website will receive as an empty JavaScript dictionary.
You can pass RESULT_CANCELED
if the transaction was not completed in the
payment app, for example, if the user failed to type in the correct PIN code for
their account in the payment app. The browser may let the user choose a
different payment app.
Kotlin
setResult(Activity.RESULT_CANCELED)
finish()
Java
setResult(Activity.RESULT_CANCELED);
finish();
If the activity result of a payment response received from the invoked payment
app is set to RESULT_OK
, then Chrome will check for non-empty methodName
and
details
in its extras. If the validation fails Chrome will return a rejected
promise from request.show()
with one of the following developer facing error
messages:
'Payment app returned invalid response. Missing field "details".'
'Payment app returned invalid response. Missing field "methodName".'
Permission
The activity can check the caller with its getCallingPackage()
method.
Kotlin
val caller: String? = callingPackage
Java
String caller = getCallingPackage();
The final step is to verify the caller's signing certificate to confirm that the calling package has the right signature.
Step 4: Verify the caller's signing certificate
You can check the caller's package name with Binder.getCallingUid()
in
IS_READY_TO_PAY
, and with Activity.getCallingPackage()
in PAY
. In order to
actually verify that the caller is the browser you have in mind, you should
check its signing certificate and make sure that it matches with the correct
value.
If you're targeting API level 28 and later and are integrating with a browser
that has a single signing certificate, you can use
PackageManager.hasSigningCertificate()
.
Kotlin
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
)
Java
String packageName = … // The caller's package name
byte[] certificate = … // The correct signing certificate
boolean verified = packageManager.hasSigningCertificate(
callingPackage,
certificate,
PackageManager.CERT_INPUT_SHA256);
PackageManager.hasSigningCertificate()
is preferred for single certificate
browsers, because it correctly handles certificate rotation. (Chrome has a
single signing certificate.) Apps that have multiple signing certificates cannot
rotate them.
If you need to support API levels 27 and earlier, or if you need to handle
browsers with multiple signing certificates, you can use
PackageManager.GET_SIGNATURES
.
Kotlin
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)
Java
String packageName = … // The caller's package name
Set<String> expected = … // The correct set of signing certificates
PackageInfo packageInfo =
packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
Set<String> actual = new HashSet<>();
for (Signature it : packageInfo.signatures) {
actual.add(SerializeByteArrayToString(sha256.digest(it.toByteArray())));
}
boolean verified = actual.equals(expected);
Debug
Use the following command to observe errors or informational messages:
adb logcat | grep -i pay