Three tickets can describe the same Restore Purchases button and mean three unrelated things. One says the button did nothing. One says it worked and then premium appeared on a second account. One says the subscription was bought on your website and the button cannot find it. Only the second is a transfer. The third is outside what a store restore can answer at all.
A restore asks one party a single question. RevenueCat describes the call as reactivating "any content that had previously been purchased from the same store account", meaning the Apple, Google or Amazon account signed in on that device right now. It does not ask your backend, it does not take the customer's email address, and it cannot see a purchase made through Stripe on the web. Each of those three tickets therefore starts with a different question, and only one of them is about your Dart code.
What the restore call actually asks for
Four identifiers are in play, and support conversations go wrong when two of them get used interchangeably.
The store account is the Apple or Google account signed in on the device. It owns the receipt. The app account is whatever your product calls a user, which RevenueCat knows as the App User ID. The original purchase is the transaction attached to the store account. The entitlement is what your code actually checks before granting access, and it belongs to an App User ID rather than to a device.
A restore pushes the store account's receipt at RevenueCat and asks it to attach the resulting purchases to the App User ID that is currently active. Whether that attachment is allowed is decided by one setting in your RevenueCat project, not by the SDK and not by the code calling it.
Two platform rules shape the button itself. Apple's App Store Review Guidelines say under 3.1.1 that "you should make sure you have a restore mechanism for any restorable in-app purchases", and 3.1.2(a) requires that subscriptions "must work on all of the user's devices where the app is available". Those two lines are the case for shipping the button even when every customer has an account with you.
The call also has to stay on a user action. RevenueCat is explicit that restorePurchases "should not be triggered programmatically, since it may cause OS level sign-in prompts to appear". If you want purchases synced on launch or after login, the documented method for that is syncPurchases, which does not raise those prompts. Calling restore on every cold start is how an app starts showing a sign-in sheet to people who were only opening a list screen.
Read the returned object, not the absence of an error
In Flutter the call is Purchases.restorePurchases(), which returns a Future<CustomerInfo> and throws a PlatformException when the restore itself fails. A completed call is not a restored purchase. It means the store answered, and the answer may contain nothing.
So the success branch has to read entitlements, and the failure branch has to separate a cancelled prompt from a real problem. The package exports a helper for that: PurchasesErrorHelper.getErrorCode converts a PlatformException into a PurchasesErrorCode.
import 'package:flutter/services.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
enum RestoreOutcome { granted, nothingFound, cancelled, ownedByAnotherAccount, failed }
Future<RestoreOutcome> restoreForUser({required String entitlementId}) async {
try {
final customerInfo = await Purchases.restorePurchases();
final entitlement = customerInfo.entitlements.all[entitlementId];
if (entitlement != null && entitlement.isActive) {
return RestoreOutcome.granted;
}
// The store replied and this account owns nothing active for this entitlement.
return RestoreOutcome.nothingFound;
} on PlatformException catch (error) {
switch (PurchasesErrorHelper.getErrorCode(error)) {
case PurchasesErrorCode.purchaseCancelledError:
// The customer dismissed the OS sign-in or purchase sheet. Nothing changed.
return RestoreOutcome.cancelled;
case PurchasesErrorCode.receiptAlreadyInUseError:
case PurchasesErrorCode.receiptInUseByOtherSubscriberError:
return RestoreOutcome.ownedByAnotherAccount;
default:
return RestoreOutcome.failed;
}
}
}
This code is a reference implementation. Every method, enum member and signature was checked against the purchases_flutter 10.15.2 API reference, and none of it was run against a store sandbox for this article. The five cases at the end are the verification it still needs.
The nothingFound branch is the one most easily mistaken for a broken button. A restore that finds nothing is a correct restore with an empty answer, so the message on that branch should name the store account that was asked instead of apologising for a failure that did not occur.
Five results, and what each one means

One call produces a small, closed set of outcomes. Naming each one in your own code is what reduces a vague ticket to a single question.
| Observed result | What the SDK gave you | Most likely cause | First action |
|---|---|---|---|
| Entitlement active after restore | CustomerInfo with an active entitlement | The store account owns a current purchase | Nothing. Dismiss the paywall and re-read entitlement state |
| Nothing restored, no error | CustomerInfo with no active entitlement | The signed-in store account never bought it, or the purchase expired | Confirm which store account is signed in on the device |
| Access moved to another account | CustomerInfo with an active entitlement on the new App User ID | The project's restore behavior transferred the purchase | Read the restore behavior in the dashboard before touching code |
| Error about a receipt in use | receiptAlreadyInUseError or receiptInUseByOtherSubscriberError | The receipt belongs to a different App User ID and the project blocks transfers | Sign the customer into the account that owns it, or change the behavior |
| Prompt appeared and nothing happened | purchaseCancelledError | The customer dismissed the OS sheet | Treat it as a no-op and leave the paywall as it was |
RevenueCat's error reference describes RECEIPT_ALREADY_IN_USE as the case where "there is an identical receipt already in use by another subscriber", with the resolution being to sign in as the App User ID that owns the receipt or to restore, and it recommends a transfer behavior for most apps. The same page says a cancelled purchase needs no action and the customer was not charged.
The project setting that decides who keeps access
The third and fourth rows above are configuration, not bugs. RevenueCat exposes one dropdown at Project settings > General, and the behavior set there affects every app under the project. There is also a separate toggle for using a different restore behavior in sandbox, which is how you test a transfer without changing production.
| Behavior | Documented effect | Support outcome you get |
|---|---|---|
| Transfer to new App User ID (default) | Transfers purchases between identified App User IDs as needed, and only one customer at a time has access | A second account can take access from the first, quietly |
| Transfer if there are no active subscriptions | Transfers unless the purchase contains an active subscription | Active subscribers keep access; one-time purchases still move |
| Keep with original App User ID | Returns an error when a different App User ID tries to restore the original purchaser's transaction | A clear error instead of a silent move, at the cost of self-service recovery |
| Share between App User IDs (legacy) | Merges App User IDs that restore the same subscription, on restore requests only | Available only to legacy projects that already had it on |
Two details on that page settle most disagreements about who should have access. The dropdown governs purchases attached to an identified App User ID; when the current owner is an anonymous ID, that ID is aliased with the new one instead. And the behavior also applies to a new purchase whose receipt already belongs to a different identified ID, so the same setting is involved in a purchase conflict that nobody called a restore.
If you picked Keep with original App User ID, the error in row four is the setting working as documented, and the fix is a sign-in flow rather than an SDK change. If you are on the default and access keeps appearing on second accounts, the question to answer is which accounts legitimately share a device. The separate decision of how identity changes at login and logout is covered in the walkthrough of switching RevenueCat users without leaking entitlements.
Why an anonymous customer sometimes cannot be recovered
Recovery depends on whether the purchase is still carried on the store receipt. Without a custom ID, the SDK generates an anonymous App User ID prefixed with $RCAnonymousID: and caches it on the device. A reinstall clears that cache and produces a new one, and RevenueCat states plainly that anonymous App User IDs cannot share subscription status across apps and platforms. For a subscription this is usually survivable, because a restore can pull the receipt back from the store account.
For anything the receipt stops carrying, it is not. Consumables and non-renewing subscriptions, by RevenueCat's own documentation, "can only be restored by using an account system with custom App User IDs", because those purchases leave the store receipt once the transaction finishes. There is a sharper version of this on Android: Google removed the ability to query consumed one-time purchases in Billing Client 8, so on affected SDK versions those purchases cannot be restored, and an anonymous customer's ID is not easily recoverable after a reinstall.
That is a product constraint rather than a bug to fix in Dart. If you sell one-time content, an account at purchase time is the mechanism that makes it recoverable, and no restore button substitutes for it.
A web purchase travels a different path

A Stripe purchase made in a browser was never on the device's store receipt, so there is nothing for a store restore to find. Offering the same Restore Purchases button as the fix for a web subscription produces the empty result in row two and a second ticket.
RevenueCat's documented route is a redemption link. The customer gets a link after the web purchase, the link opens your app through a custom URL scheme, and the SDK redeems it. In Flutter you obtain the deep link yourself with something like app_links, then hand it to the SDK:
final redemption = await Purchases.parseAsWebPurchaseRedemption(incomingUrl);
if (redemption == null) {
return; // Not a redemption link. Let your normal deep-link routing handle it.
}
final result = await Purchases.redeemWebPurchase(redemption);
switch (result) {
case WebPurchaseRedemptionSuccess(:final customerInfo):
// Entitlements granted to the current App User ID.
case WebPurchaseRedemptionPurchaseBelongsToOtherUser():
// Ask the customer to sign in with the account that bought it.
case WebPurchaseRedemptionExpired(:final obfuscatedEmail):
// A new link was sent; tell them which address it went to.
case WebPurchaseRedemptionInvalidToken():
case WebPurchaseRedemptionError(:final error):
// Show a retry path, not a paywall.
}
The redemption link documentation sets the boundaries you need before promising this in a support reply: it needs purchases_flutter 8.4.0 or later, the link only opens from the mobile app where it was configured, and redemption fails if the app is not installed, if the link is opened in a desktop browser, or if the link is more than 60 minutes old. An expired link triggers a fresh email unless recovery emails are disabled for that configuration, in which case the SDK returns a generic error rather than the expired case.
Server-side reconciliation is the other half, and it has its own timing. RevenueCat's Stripe guidance notes that a RENEWAL webhook is sent only after Stripe confirms the invoice has been paid, and advises holding access until an EXPIRATION event arrives rather than dropping it when a cached expiry passes. Stripe adds two constraints on any handler you write: it does not guarantee that events arrive in the order they were generated, and an endpoint "might occasionally receive the same event more than once", so you track event IDs and verify the signature instead of trusting arrival order. If your access column and the real subscription state disagree, the pattern to review is how subscription lifecycle states drift from actual access rather than the restore call.
What support can ask for, and what it should never ask for
A restore ticket is answerable with four facts, none of them sensitive.
- The platform and whether the app was reinstalled or moved to a new device.
- Which store account is signed in on the device now, confirmed in the device settings by the customer, never by sending you a password.
- Whether they are signed into your app with the same account they used when they bought.
- Where the purchase was made: in the app, or on the web.
Never ask for the store password, a screenshot of a receipt email containing an order ID, or a payment method detail. None of it is needed to resolve any of the five rows, and asking for it teaches customers to send credentials to anyone who asks in your name. If you need the customer's RevenueCat identity, read the App User ID from your own session rather than from them, and log it alongside the restore outcome so the next ticket starts with the answer already attached.
Run these five cases before you change code
Run these in sandbox, on one device, before changing any code. They establish what a restore does under your configuration.
- Same store account, same app account. Buy in sandbox, reinstall, sign into the same app account, restore. Expect an active entitlement.
- Same store account, different app account. Sign into a second app account and restore. The result should match your dropdown: a transfer on the default, or a receipt error on Keep with original App User ID. Anything else means your reading of the setting is wrong.
- Expired purchase. Let a sandbox subscription lapse, then restore. Expect a returned
CustomerInfowith no active entitlement, and confirm your copy does not call that a failure. - Cancelled prompt. Trigger restore and dismiss the OS sheet. Expect
purchaseCancelledErrorand no visible state change. - Web purchase. Buy through your web flow, open the redemption link on the device, and confirm the entitlement arrives through the redemption result rather than through a store restore.
Case two is the one worth running first, because it is the only one whose correct answer you cannot know from the SDK alone. Open Project settings > General, write down the configured behavior, then run the case and check that the device agrees with the dashboard. If your paywall also needs work on what it shows while entitlement state is still unresolved, the gating patterns in the RevenueCat subscription paywall walkthrough cover that side of the screen.
