GoogleChromeLabs / GoogleChromeLabs/bubblewrap

TWA Play Billing - PaymentRequest.show() fails with AbortError: Invalid state on some devices while working on others

Open
#1,046 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
3.1k
Forks
319
PR merge metrics
No merged PRs in 30d

Description

## Description

I'm integrating Google Play Billing in a Trusted Web Activity using the Digital Goods API.

The implementation follows the official documentation:
https://developer.chrome.com/docs/android/trusted-web-activity/receive-payments-play-billing

Everything works correctly on some devices, but on others `PaymentRequest.show()` immediately throws:

```
AbortError: Invalid state.
```

The strange part is that the exact same app version and even the same Google account work on one device but fail on another.

## Environment

- Trusted Web Activity generated with PWABuilder
- App distributed through Google Play Closed Testing
- Installed directly from Google Play (not sideloaded)
- Google Play Billing enabled
- Digital Goods API
- Payment Request API

## What I've verified

- `window.getDigitalGoodsService` exists
- `PaymentRequest` exists
- `getDigitalGoodsService("https://play.google.com/billing")` succeeds
- `service.getDetails()` returns all products correctly
- `canMakePayment()` returns `true`
- The correct SKU is found (`fynz_mensal`)
- Products are Active in Play Console
- Testers are enrolled in Closed Testing
- License testers are configured
- The app is installed from the Play Store
- Same AAB version on every tested device

## Sentry logs

```
preload:start
supportsGoogleBilling=true

preload:products-loaded
productsFound=[
"fynz_mensal",
"fynz_semestral",
"fynz_anual"
]

preload:done
canMakePayment=true

checkout
googleBillingReady=true

route-decided
isGoogleBilling=true

before-show

182ms later

AbortError: Invalid state.
```

The exception occurs exactly here:

```javascript
const response = await request.show();
```

The Google Play purchase dialog is never displayed.

Code:

```
const [googleBilling, setGoogleBilling] = useState<{
service: any;
products: any[];
canMakePayment: boolean;
} | null>(null);

useEffect(() => {
let cancelled = false;

(async () => {
const supportsGoogleBilling =
typeof (window as any).getDigitalGoodsService === "function" &&
typeof (window as any).PaymentRequest === "function";

if (!supportsGoogleBilling) return;

try {
const service = await (window as any).getDigitalGoodsService(
"https://play.google.com/billing"
);

const googleIds = [
"fynz_mensal",
"fynz_semestral",
"fynz_anual",
];

const products = await service.getDetails(googleIds);

const testRequest = new PaymentRequest(
[{ supportedMethods: "https://play.google.com/billing", data: { sku: googleIds[0] } }],
{ total: { label: "check", amount: { currency: "BRL", value: "0.00" } } }
);
const canMakePayment = await testRequest.canMakePayment();

if (!cancelled) {
setGoogleBilling({ service, products, canMakePayment });
}
} catch (e) {
console.warn("Google Play Billing indisponível:", e);
}
})();

return () => {
cancelled = true;
};
}, []);

async function iniciarAssinatura(priceId: string) {
if (loadingCheck) return;

try {
setLoadingCheck(priceId);

let googlePlayId = "";

switch (priceId) {
case process.env.NEXT_PUBLIC_STRIPE_PLAN_MENSAL:
googlePlayId = "fynz_mensal";
break;

case process.env.NEXT_PUBLIC_STRIPE_PLAN_SEMESTRAL:
googlePlayId = "fynz_semestral";
break;

case process.env.NEXT_PUBLIC_STRIPE_PLAN_ANUAL:
googlePlayId = "fynz_anual";
break;
}

const isGoogleBilling =
googlePlayId !== "" &&
googleBilling !== null &&
googleBilling.canMakePayment &&
googleBilling.products.some((p: any) => p.itemId === googlePlayId);

if (isGoogleBilling) {
try {
const request = new PaymentRequest(
[
{
supportedMethods: "https://play.google.com/billing",
data: { sku: googlePlayId },
},
],
{
total: {
label: "Fynz Premium",
amount: { currency: "BRL", value: "0.00" },
},
}
);

const response = await request.show();

const purchaseToken =
(response as any).details?.purchaseToken ??
(response as any).details?.token;

await response.complete("success");

if (!purchaseToken) {
showToast("error", "não recebido.");
return;
}

const user = await getDadosUsuario();
if (!user) {
showToast("info", "Você precisa estar logado.");
return;
}

const salvar = await fetch("/api/checkout/google", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: user.id,
googlePlayId,
purchaseToken,
}),
});

const json = await salvar.json();

if (!salvar.ok) {
showToast("error", json.error || "Erro ao salvar assinatura.");
return;
}

location.reload();
} catch (e) {
Sentry.captureException(e);
console.error(e);
showToast("error", "Erro ao processar a compra.");
}
return;
}

const user = await getDadosUsuario();

if (!user) {
showToast("info", "Você precisa estar logado.");
return;
}

const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
priceId,
userId: user.id,
userEmail: user.email,
}),
});

const dados = await response.json();

if (dados.url) {
location.href = dados.url;
} else {
showToast("error", dados.error || "Erro ao iniciar assinatura.");
}
} catch (e) {
Sentry.captureException(e);
console.error(e);
showToast("error", "Erro ao iniciar assinatura.");
} finally {
setLoadingCheck(null);
}
}
```

## Device tests

Using the **same Google account**:

### Working
- Motorola device
- Xiaomi (Android 13)

### Failing
- Samsung Galaxy A30 (Android 10)
- Another Samsung device
- Another Motorola device

The behavior changes depending on the device, even when using the same Google account.

## Notes

Everything before `request.show()` succeeds:

- Digital Goods API is available
- Products are returned correctly
- `canMakePayment()` returns `true`

The only failing step is `request.show()`, which immediately throws `AbortError: Invalid state.`

## Question

Is this a known issue with Trusted Web Activities, the Digital Goods API, or the Payment Request API?

Are there any known device compatibility issues or additional debugging methods that could explain why `request.show()` is aborted even though:

- `getDetails()` succeeds
- `canMakePayment()` returns `true`
- the app is installed from Google Play
- Google Play Billing is correctly configured
- the same Google account works on some devices but not on others

Any guidance would be greatly appreciated.

Additional finding

On one Samsung device, changing the default browser from Samsung Internet to Google Chrome made PaymentRequest.show() work immediately without changing the app or Google account.

This suggests the issue may depend on the browser implementation used by the TWA rather than the Play Billing configuration itself.

Contributor guide

Open the contributing guide

Research direction

Start by reproducing the reported checkout path through PaymentRequest.show() on the listed Samsung and Motorola devices, comparing Samsung Internet with Google Chrome. Focus on the browser implementation used by the Trusted Web Activity, since getDigitalGoodsService(), getDetails(), and canMakePayment() succeed before the failure. Done means identifying a reproducible compatibility cause or a specific Bubblewrap change, with evidence from the device and browser comparisons.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
mobile-dev, payments
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.