[Android] Isssue: "replacementModeAndroid: 6", Google Play message: "Something went wrong on our end. Please try again."
Issue
hi, one here is my code for buying a subscription, upgrade/downgrade is already enabled here (the problem occurs with Android during downgrade) one, it seems like I did everything according to the documentation, but every time I try to downgrade to replacementModeAndroid: 6, I get this screenshot maybe I did something wrong or on the contrary I have to finalize something. I will also attach a second screenshot of the organization of my subscriptions (maybe I got something wrong here?)
Buy subscription code
const buySubscription = async (isUpgradingPlan: boolean) => {
if (!selectedPlanId) return;
if (selectedPlanId === FREE_SUBSCRIPTION_PRODUCT.id) {
handleBuySubscriptionCallback?.();
return;
}
setIsPurchaseLoading(true);
try {
const available = await getAvailablePurchases();
const alreadyActive = available.some(p => selectedPlanId === p.productId);
if (alreadyActive) {
console.log("[IAP] Subscription already active, skipping new purchase");
setIsPurchaseLoading(false);
handleBuySubscriptionCallback?.();
return;
}
const activeSubscription =
available.find(p => p.productId === subscriptionsIAP[0]?.id) ??
undefined;
const purchaseTokenAndroid = activeSubscription?.purchaseToken;
const subscription = subscriptionsIAP[0] as ProductSubscriptionAndroid;
const androidSelectedPlanId = selectedPlanId.replace("_", "-"); // because android offers are named like this: "premium-month" but ios subscriptions are named like this: "premium_month"
const replacementModeAndroid = isUpgradingPlan ? 1 : 6;
const chosenOfferToken =
subscription?.subscriptionOfferDetailsAndroid?.find(
offer => offer.basePlanId === androidSelectedPlanId,
)?.offerToken ?? "";
const otherOffers =
subscription?.subscriptionOfferDetailsAndroid
?.filter(offer => offer.basePlanId !== androidSelectedPlanId)
?.map(offer => ({
sku: subscription.id,
offerToken: offer.offerToken,
})) ?? [];
console.log("skus:", [androidSelectedPlanId]);
console.log("offerToken:", chosenOfferToken);
console.log("purchaseToken:", purchaseTokenAndroid);
console.log("replacementMode:", replacementModeAndroid);
await requestPurchase({
request: {
ios: { sku: selectedPlanId, appAccountToken: userPublicId },
android: {
skus: [subscription.id],
subscriptionOffers: [
{ sku: subscription.id, offerToken: chosenOfferToken },
...otherOffers,
],
purchaseTokenAndroid,
replacementModeAndroid,
obfuscatedAccountIdAndroid: userPublicId,
},
},
type: "subs",
});
dispatch(getUserInfoThunk());
dispatch(getUserLimitsLeftThunk());
handleBuySubscriptionCallback?.();
} catch (error) {
console.error("Buy subscription error:", error);
} finally {
setIsPurchaseLoading(false);
}
};
Environment:
react-native-iap: "14.4.19" react-native: "0.81.0" Platforms: "Android"
Which version are you using? You've not filled the appropriate template
Hi @hyochan, I've updated the "Environment" part, do I need to add anything else?
@arseni-siniauski When attempting to downgrade a subscription using replacementModeAndroid: 6 (DEFERRED), Google Play shows "Something went wrong on our end. Please try again."
Root Cause Analysis
1. Subscription Configuration Issue (Most Likely)
Looking at your Google Play Console screenshot:
- Product ID:
subscription_plans - Base Plans:
premium-month,pro-month
Issue: The premium-month plan shows "Backward compatibility" badge. This indicates a legacy subscription configuration that may not fully support the DEFERRED replacement mode.
2. Incorrect subscriptionOffers Array
Your current code includes all other offers in the subscriptionOffers array:
const otherOffers = subscription?.subscriptionOfferDetailsAndroid
?.filter(offer => offer.basePlanId !== androidSelectedPlanId)
?.map(offer => ({ sku: subscription.id, offerToken: offer.offerToken })) ?? [];
await requestPurchase({
request: {
android: {
subscriptionOffers: [
{ sku: subscription.id, offerToken: chosenOfferToken },
...otherOffers, // ❌ This is incorrect
],
// ...
},
},
});
Problem: The subscriptionOffers array should only contain the single offer you want to purchase, not all available offers.
3. DEFERRED Mode Limitations
Google Play Billing's DEFERRED (6) mode has specific requirements:
- Must be within the same subscription product
- May not work with certain legacy subscription configurations
- Can fail in sandbox/test environments
Recommended Solution
Fix 1: Remove otherOffers from subscriptionOffers
const buySubscription = async (isUpgradingPlan: boolean) => {
// ... existing code ...
const chosenOfferToken = subscription?.subscriptionOfferDetailsAndroid?.find(
offer => offer.basePlanId === androidSelectedPlanId,
)?.offerToken ?? "";
await requestPurchase({
request: {
ios: { sku: selectedPlanId, appAccountToken: userPublicId },
android: {
skus: [subscription.id],
subscriptionOffers: [
{ sku: subscription.id, offerToken: chosenOfferToken },
// ✅ Only include the chosen offer, remove otherOffers
],
purchaseTokenAndroid,
replacementModeAndroid: isUpgradingPlan ? 1 : 6,
obfuscatedAccountIdAndroid: userPublicId,
},
},
type: "subs",
});
};
Fix 2: Try WITHOUT_PRORATION (3) as Alternative
If DEFERRED continues to fail, try using WITHOUT_PRORATION:
const replacementModeAndroid = isUpgradingPlan
? 1 // WITH_TIME_PRORATION for upgrades
: 3; // WITHOUT_PRORATION for downgrades
Fix 3: Check Google Play Console Configuration
- Verify both base plans are in the same subscription
- Remove "Backward compatibility" if possible - This legacy setting can cause issues with modern replacement modes
- Ensure both plans have proper pricing configured for all 174 regions
Android Replacement Modes Reference
| Value | Mode | Description |
|---|---|---|
1 |
WITH_TIME_PRORATION | Immediate change with prorated credit (default for upgrades) |
2 |
CHARGE_PRORATED_PRICE | Immediate change with prorated charge (upgrade only) |
3 |
WITHOUT_PRORATION | Immediate change, no proration |
5 |
CHARGE_FULL_PRICE | Immediate change, charge full price |
6 |
DEFERRED | Change takes effect at next renewal (recommended for downgrades) |
Additional Debugging Steps
-
Check logcat for detailed error:
adb logcat | grep -i billing -
Verify purchaseToken is valid:
console.log("purchaseToken:", purchaseTokenAndroid); // Ensure this is not null/undefined -
Test with a fresh subscription:
- Create a new test account
- Purchase
pro-monthfirst - Then attempt downgrade to
premium-month
-
Try on a real device:
- Some billing features don't work correctly on emulators
Related Documentation
@hyochan hi, thanks for the detailed answer, only today I was able to test all the steps. I managed to remove the "Backward compatibility" for my subscription via the Google Developer API. but unfortunately it didn't help with solving the problem of "Something went wrong on our end. Please try again" I also checked the operation of "Fix 2: Try WITHOUT_PRORATION (3) as Alternative", it worked, but this is not the flow that I expect, I expect that the subscription (lower in price) after calling downgrade, will wait for the end of the current (higher in price) I will be glad of any help, thank you in advance
Updated code
const buySubscription = async (isUpgradingPlan: boolean) => {
if (!selectedPlanId) return;
if (selectedPlanId === FREE_SUBSCRIPTION_PRODUCT.id) {
handleBuySubscriptionCallback?.();
return;
}
setIsPurchaseLoading(true);
try {
const available = await getAvailablePurchases();
const alreadyActive = available.some(p => selectedPlanId === p.productId);
if (alreadyActive) {
console.log("[IAP] Subscription already active, skipping new purchase");
setIsPurchaseLoading(false);
handleBuySubscriptionCallback?.();
return;
}
const activeSubscription =
available.find(p => p.productId === subscriptionsIAP[0]?.id) ??
undefined;
const purchaseTokenAndroid = activeSubscription?.purchaseToken;
const subscription = subscriptionsIAP[0] as ProductSubscriptionAndroid;
const androidSelectedPlanId = selectedPlanId.replace("_", "-"); // because android offers are named like this: "premium-month" but ios subscriptions are named like this: "premium_month"
const replacementModeAndroid = isUpgradingPlan ? 1 : 6;
const chosenOfferToken =
subscription?.subscriptionOfferDetailsAndroid?.find(
offer => offer.basePlanId === androidSelectedPlanId,
)?.offerToken ?? "";
console.log("skus:", [androidSelectedPlanId]);
console.log("offerToken:", chosenOfferToken);
console.log("purchaseToken:", purchaseTokenAndroid);
console.log("replacementMode:", replacementModeAndroid);
await requestPurchase({
request: {
ios: { sku: selectedPlanId, appAccountToken: userPublicId },
android: {
skus: [subscription.id],
subscriptionOffers: [
{ sku: subscription.id, offerToken: chosenOfferToken },
],
purchaseTokenAndroid,
replacementModeAndroid,
obfuscatedAccountIdAndroid: userPublicId,
},
},
type: "subs",
});
dispatch(getUserInfoThunk());
dispatch(getUserLimitsThunk());
dispatch(getUserLimitsLeftThunk());
handleBuySubscriptionCallback?.();
} catch (error) {
console.error("Buy subscription error:", error);
} finally {
setIsPurchaseLoading(false);
}
};
Additional info:
Logs from Real device with debug build:
2025-11-25 19:10:03.248 14193-14709 RnIap com.myappmyapp D getAvailablePurchases result: [{"id":"GPA.3331-2907-6590-42248","sku":"subscription_plans"}]
2025-11-25 19:10:03.371 14193-14300 ReactNativeJS com.myappmyapp I 'skus:', [ 'premium-month' ]
2025-11-25 19:10:03.371 14193-14300 ReactNativeJS com.myappmyapp I 'offerToken:', '<token>'
2025-11-25 19:10:03.372 14193-14300 ReactNativeJS com.myappmyapp I 'purchaseToken:', '<token>'
2025-11-25 19:10:03.373 14193-14300 ReactNativeJS com.myappmyapp I 'replacementMode:', 6
2025-11-25 19:10:03.374 14193-14709 RnIap com.myappmyapp D requestPurchase payload: {"androidSkus":["subscription_plans"],"hasIOS":false}
2025-11-25 19:10:03.375 14193-14244 RnIap com.myappmyapp D initConnection payload: null
2025-11-25 19:10:03.375 14193-14244 RnIap com.myappmyapp D initConnection result: true
2025-11-25 19:10:03.377 14193-14709 RnIap com.myappmyapp D requestPurchase.native payload: {"skus":["subscription_plans"],"type":"subs","offerCount":1}
2025-11-25 19:10:03.395 16299-16310 Finsky com.android.vending I [153] com.myappmyapp: Account from first account - [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
2025-11-25 19:10:03.396 16299-16310 Finsky com.android.vending I [153] Billing preferred account via installer for com.myappmyapp: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
2025-11-25 19:10:03.397 16299-16310 Finsky com.android.vending W [153] Got api version 27 for account: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
2025-11-25 19:10:03.420 14193-14193 unknown:BridgelessReact com.myappmyapp W ReactHost{0}.onUserLeaveHint(activity)
2025-11-25 19:10:03.421 14193-14193 FirebaseSessions com.myappmyapp D App backgrounded on com.myappmyapp
2025-11-25 19:10:03.422 14193-14193 unknown:BridgelessReact com.myappmyapp W ReactHost{0}.onHostPause(activity)
2025-11-25 19:10:03.425 14193-14193 unknown:BridgelessReact com.myappmyapp W ReactContext.onHostPause()
2025-11-25 19:10:03.425 14193-14193 InCallManager com.myappmyapp D onPause()
2025-11-25 19:10:03.461 14193-14193 FirebaseSessions com.myappmyapp D App foregrounded on com.myappmyapp
2025-11-25 19:10:03.476 14193-14193 BufferQueueConsumer com.myappmyapp D [](id:377100000017,api:0,p:-1,c:14193) connect: controlledByApp=false
2025-11-25 19:10:03.481 14193-14252 BLASTBufferQueue com.myappmyapp D [VRI[ProxyBillingActivity]#23](f:0,a:1) acquireNextBufferLocked size=1080x2400 mFrameNumber=1 applyTransaction=true mTimestamp=182987902718444(auto) mPendingTransactions.size=0 graphicBufferId=60958470832220 transform=0
2025-11-25 19:10:03.481 14193-14252 Parcel com.myappmyapp W Expecting binder but got null!
2025-11-25 19:10:03.483 14193-14193 FirebaseSessions com.myappmyapp D App backgrounded on com.myappmyapp
2025-11-25 19:10:03.542 16299-16299 SBE-DEBUG com.android.vending D init scroll scenario
2025-11-25 19:10:03.542 16299-16299 SBE-DEBUG com.android.vending D init scroll scenario
2025-11-25 19:10:03.544 16299-16299 SBE-DEBUG com.android.vending D init scroll scenario
2025-11-25 19:10:03.544 16299-16299 SBE-DEBUG com.android.vending D init scroll scenario
2025-11-25 19:10:03.563 16299-16364 PaySecureElementClient com.android.vending D Felica app not found; returning isSecureElementAvailable = false!
2025-11-25 19:10:03.577 16299-16299 Finsky com.android.vending I [2] setOwned true for [email protected]
2025-11-25 19:10:03.588 16299-15197 FA com.android.vending W Failed to retrieve Firebase Instance Id
2025-11-25 19:10:03.594 16299-16299 BufferQueueConsumer com.android.vending D [](id:3fab0000000d,api:0,p:-1,c:16299) connect: controlledByApp=false
2025-11-25 19:10:03.602 16299-8720 skia com.android.vending D GrGLMakeAssembledInterface verStr OpenGL ES 3.2 v1.r32p1-01eac0.826ad53ffca045d2012ca444a611fa06
2025-11-25 19:10:03.603 16299-8720 skia com.android.vending D GrGLMakeAssembledGLESInterface verStr OpenGL ES 3.2 v1.r32p1-01eac0.826ad53ffca045d2012ca444a611fa06
2025-11-25 19:10:03.603 16299-8720 skia com.android.vending D extensions init verString=OpenGL ES 3.2 v1.r32p1-01eac0.826ad53ffca045d2012ca444a611fa06
2025-11-25 19:10:03.606 16299-16303 android.vending com.android.vending W Missing inline cache for void thb.g(brkp, boolean)
2025-11-25 19:10:03.606 16299-16303 android.vending com.android.vending W Missing inline cache for void thb.g(brkp, boolean)
2025-11-25 19:10:03.609 16194-16194 BoundBrokerSvc com.google.android.gms.persistent D onRebind: Intent { act=com.google.android.gms.presencemanager.service.START dat=chimera-action:/... cmp=com.google.android.gms/.chimera.PersistentApiService }
2025-11-25 19:10:03.614 16194-16194 BoundBrokerSvc com.google.android.gms.persistent D onRebind: Intent { act=com.google.android.gms.presencemanager.service.INTERNAL_IDENTITY dat=chimera-action:/... cmp=com.google.android.gms/.chimera.PersistentApiService }
2025-11-25 19:10:03.615 16299-8720 BLASTBufferQueue com.android.vending D [VRI[LockToPortraitUiBuilderHostActivity]#13](f:0,a:1) acquireNextBufferLocked size=1080x2400 mFrameNumber=1 applyTransaction=true mTimestamp=182988036456290(auto) mPendingTransactions.size=0 graphicBufferId=70003671957556 transform=0
2025-11-25 19:10:03.616 16299-8720 Parcel com.android.vending W Expecting binder but got null!
2025-11-25 19:10:03.622 1660-1706 ActivityTaskManager system_server I Displayed com.android.vending/com.google.android.finsky.billing.acquire.LockToPortraitUiBuilderHostActivity: +215ms
2025-11-25 19:10:03.626 16194-15118 NetworkScheduler com.google.android.gms.persistent I (REDACTED) Error inserting %s
2025-11-25 19:10:03.633 16299-16299 ImeFocusController com.android.vending V onWindowFocus: DecorView@8cb203f[LockToPortraitUiBuilderHostActivity] softInputMode=STATE_UNSPECIFIED|ADJUST_RESIZE|IS_FORWARD_NAVIGATION
2025-11-25 19:10:03.633 16299-16299 ImeFocusController com.android.vending V Restarting due to isRestartOnNextWindowFocus as true
2025-11-25 19:10:03.633 16299-16299 ImeFocusController com.android.vending D onViewFocusChanged, view=DecorView@8cb203f[LockToPortraitUiBuilderHostActivity], mServedView=null
2025-11-25 19:10:03.633 16299-16299 ImeFocusController com.android.vending V checkFocus: view=null next=DecorView@8cb203f[LockToPortraitUiBuilderHostActivity] immDelegate=delegate{37329ce displayId=0} force=true package=<none>
2025-11-25 19:10:03.639 4240-4240 GoogleInpu...hodService com...gle.android.inputmethod.latin I GoogleInputMethodService.onFinishInput():3220
2025-11-25 19:10:03.640 4240-4240 GoogleInpu...hodService com...gle.android.inputmethod.latin I GoogleInputMethodService.updateDeviceLockedStatus():2114 repeatCheckTimes = 0, unlocked = true
2025-11-25 19:10:03.640 4240-4240 GoogleInpu...hodService com...gle.android.inputmethod.latin I GoogleInputMethodService.onStartInput():1919 onStartInput(EditorInfo{inputType=0x0(NULL) imeOptions=0x0 privateImeOptions=null actionName=UNSPECIFIED actionLabel=null actionId=0 initialSelStart=-1 initialSelEnd=-1 initialCapsMode=0x0 hintText=null label=null packageName=com.android.vending fieldId=-1 fieldName=null extras=null}, false)
2025-11-25 19:10:03.641 4240-4240 GoogleInpu...hodService com...gle.android.inputmethod.latin I GoogleInputMethodService.updateDeviceLockedStatus():2114 repeatCheckTimes = 2, unlocked = true
Logs from Real device with "Internal testing" build(logs after calling buySubscription)(adb logcat | grep -i billing):
11-25 19:47:01.865 16299 16131 I Finsky : [574] Billing preferred account via installer for com.mayappmyapp: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
11-25 19:47:01.996 16299 16131 I Finsky : [574] Billing preferred account via installer for com.mayappmyapp: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
11-25 19:47:02.016 16299 16131 I Finsky : [574] Billing preferred account via installer for com.mayappmyapp: [GbxKmeOMXdtZZM8LVrxy5zF5O4OGSC4bdMv2FWIEn_Y]
11-25 19:47:02.108 19211 19273 D BLASTBufferQueue: [VRI[ProxyBillingActivity]#15](f:0,a:1) acquireNextBufferLocked size=1080x2400 mFrameNumber=1 applyTransaction=true mTimestamp=185206529616961(auto) mPendingTransactions.size=0 graphicBufferId=82510616723628 transform=0
11-25 19:47:02.229 1660 1706 I ActivityTaskManager: Displayed com.android.vending/com.google.android.finsky.billing.acquire.LockToPortraitUiBuilderHostActivity: +203ms
@arseni-siniauski
- Use a single
subscriptionOffersentry and pick an offer token that is valid for existing subscribers (skip intro/trial/new-user tags; prefer the plain price phase). - Confirm both base plans are active auto-renewing, no legacy/backward-compatibility remnants, and pricing is set in all regions.
- Pass the currently active sub’s
purchaseTokenAndroid; a stale or different token makes DEFERRED fail. - If it still shows “Something went wrong…”, log the
purchaseErrorListener/BillingClientdebugMessageand try a fresh non-intro offer on the target base plan.
@hyochan
Ok, i've checked all your cases:
-
"Use a single subscriptionOffers entry and pick an offer token that is valid for existing subscribers (skip intro/trial/new-user tags; prefer the plain price phase)" - done, u can see this in previous code implementation
-
"Confirm both base plans are active auto-renewing, no legacy/backward-compatibility remnants, and pricing is set in all regions"- done, i've checked all pricing, regions, legacy/backward-compatibility and it's all done This is one subscription with 2 base plans:
premium-month settings(all 174 prices are setted(first screenshot)):
pro-month settings(all 174 prices are setted(first screenshot)):
-
"Pass the currently active sub’s purchaseTokenAndroid; a stale or different token makes DEFERRED fail." - done, i've checked my token after completed purchase in console and after get purchaseToken from activity subscription(via
getActiveSubscriptions)
const {
connected,
subscriptions: subscriptionsIAP,
fetchProducts: fetchProductsIAP,
requestPurchase,
getActiveSubscriptions
} = useIAP();
const buySubscription = async (isUpgradingPlan: boolean) => {
// ... existing code ...
const available = await getAvailablePurchases();
const activeSubscription =
available.find(p => p.productId === subscriptionsIAP[0]?.id) ??
undefined;
const purchaseTokenAndroid = activeSubscription?.purchaseToken;
// ... existing code ...
};
- "If it still shows “Something went wrong…”, log the purchaseErrorListener/BillingClient debugMessage and try a fresh non-intro offer on the target base plan." - checked and it's shows
'Purchase error', 'Invalid arguments provided to the API'
I also have a few questions.
- Could this error be related to the fact that I did not complete the "Basic setup" for the application?
- I've checked all the
replacementModeAndroidfor downgrade (with the current higher-priced plan, the user makes a purchase of a lower-priced plan) and onlyreplacementModeAndroid: 6does not work for me? - Maybe there is an example upgrade /downgrade code in addition to the documentation, with which I can check the operation of the subscription purchase?
I will be glad of any help, thank you
@arseni-siniauski Based on your debugging:
-
Error message:
'Invalid arguments provided to the API' - All other replacement modes work (1, 2, 3, 5) - only DEFERRED (6) fails
- Subscription configuration looks correct - both base plans are active, pricing set for all 174 regions
-
purchaseToken is being retrieved correctly from
getAvailablePurchases()
Confirmed Solution: Use replacementModeAndroid: 3 for Downgrades
Since DEFERRED (6) is failing but other modes work, use WITHOUT_PRORATION (3) instead:
const replacementModeAndroid = isUpgradingPlan
? 1 // WITH_TIME_PRORATION for upgrades
: 3; // WITHOUT_PRORATION for downgrades
This is a reliable solution that works in your environment.
Possible Cause: Google Play Console "Basic Setup" Incomplete (Unverified)
Note: This is a possible explanation, not confirmed. Google's official documentation doesn't explicitly state this requirement.
The incomplete "Basic setup" in Google Play Console might affect DEFERRED mode because:
- DEFERRED involves scheduling future billing changes
- Google Play may require full app verification for deferred operations
- Immediate transaction modes (1, 2, 3, 5) have simpler validation
However, we cannot confirm this is the root cause without:
- Testing after completing Basic Setup
- Finding official Google documentation about this requirement
If You Want to Test This Theory
- Complete Google Play Console → Your App → Setup → App content
- Complete all required sections (Content rating, Data safety, Target audience, etc.)
- Test DEFERRED (6) again after completion
Implementation Examples
Basic Implementation
const buySubscription = async (isUpgradingPlan: boolean) => {
// ... existing code ...
const replacementModeAndroid = isUpgradingPlan
? 1 // WITH_TIME_PRORATION for upgrades
: 3; // WITHOUT_PRORATION for downgrades
await requestPurchase({
request: {
android: {
skus: [subscription.id],
subscriptionOffers: [
{ sku: subscription.id, offerToken: chosenOfferToken },
],
purchaseTokenAndroid,
replacementModeAndroid,
obfuscatedAccountIdAndroid: userPublicId,
},
},
type: "subs",
});
};
With Fallback Logic (Optional)
If you want to try DEFERRED first and fallback:
const buySubscription = async (isUpgradingPlan: boolean) => {
const preferredMode = isUpgradingPlan ? 1 : 6; // Try DEFERRED for downgrade
const fallbackMode = isUpgradingPlan ? 1 : 3; // Fallback to WITHOUT_PRORATION
try {
await requestPurchase({
request: {
android: {
skus: [subscription.id],
subscriptionOffers: [
{ sku: subscription.id, offerToken: chosenOfferToken },
],
purchaseTokenAndroid,
replacementModeAndroid: preferredMode,
obfuscatedAccountIdAndroid: userPublicId,
},
},
type: "subs",
});
} catch (error) {
// If DEFERRED fails with "Invalid arguments", retry with fallback
if (
!isUpgradingPlan &&
error.message?.includes('Invalid arguments')
) {
console.log('DEFERRED mode failed, retrying with WITHOUT_PRORATION');
await requestPurchase({
request: {
android: {
skus: [subscription.id],
subscriptionOffers: [
{ sku: subscription.id, offerToken: chosenOfferToken },
],
purchaseTokenAndroid,
replacementModeAndroid: fallbackMode,
obfuscatedAccountIdAndroid: userPublicId,
},
},
type: "subs",
});
} else {
throw error;
}
}
};
Example Code Reference
Here's the upgrade/downgrade example from our example app:
File: example/screens/SubscriptionFlow.tsx
// From example/screens/SubscriptionFlow.tsx
const handleUpgradeDowngrade = async (
targetBasePlanId: string,
isUpgrade: boolean,
) => {
if (!currentSubscription?.purchaseToken) {
Alert.alert('Error', 'No active subscription to upgrade/downgrade');
return;
}
const subscription = subscriptions.find(
(s) => s.id === currentSubscription.productId,
);
if (!subscription) return;
const targetOffer = (
subscription as ProductSubscriptionAndroid
).subscriptionOfferDetailsAndroid?.find(
(o) => o.basePlanId === targetBasePlanId,
);
if (!targetOffer) {
Alert.alert('Error', 'Target offer not found');
return;
}
try {
await requestPurchase({
request: {
android: {
skus: [subscription.id],
subscriptionOffers: [
{
sku: subscription.id,
offerToken: targetOffer.offerToken,
},
],
purchaseTokenAndroid: currentSubscription.purchaseToken,
// 1 = WITH_TIME_PRORATION (for upgrades)
// 3 = WITHOUT_PRORATION (for downgrades)
replacementModeAndroid: isUpgrade ? 1 : 3,
},
},
type: 'subs',
});
} catch (error) {
console.error('Upgrade/Downgrade failed:', error);
Alert.alert('Error', 'Failed to change subscription');
}
};
Key Differences: DEFERRED vs WITHOUT_PRORATION
| Aspect | DEFERRED (6) | WITHOUT_PRORATION (3) |
|---|---|---|
| When change applies | Next renewal date | Immediately |
| User keeps current plan | Yes, until renewal | No, switches now |
| Refund/Credit | None needed | None given |
| UX for downgrade | Better (user keeps paid features) | User loses features immediately |
| Google Play requirements | Stricter validation | More lenient |
Recommendation
Use replacementModeAndroid: 3 (WITHOUT_PRORATION) for downgrades. This is confirmed to work in your environment.
If you want to investigate further:
- Try completing Google Play Console Basic Setup and test DEFERRED (6) again
- Let us know the result so we can update documentation for others