Skip to content
SaaS Development12 October 2026 · 13 min read

Fix Restore Purchases Flows in Flutter Apps

A restore asks the signed-in store account one question, and one project setting decides whether access may move. Map the five results before changing code.

Fix Restore Purchases Flows in Flutter Apps

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

A square brass plate on dark slate with one keyed inlet at the top feeding five milled channels: the first ends at a blank tag seated flush in its recess, the second at an open empty recess, the third at a tag held behind a closed cross bar, the fourth at a recess covered by a screwed-down flat plate, and the fifth in solid brass with no recess at all, while a separate brown cord enters the plate through a fitting on the right edge.

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 resultWhat the SDK gave youMost likely causeFirst action
Entitlement active after restoreCustomerInfo with an active entitlementThe store account owns a current purchaseNothing. Dismiss the paywall and re-read entitlement state
Nothing restored, no errorCustomerInfo with no active entitlementThe signed-in store account never bought it, or the purchase expiredConfirm which store account is signed in on the device
Access moved to another accountCustomerInfo with an active entitlement on the new App User IDThe project's restore behavior transferred the purchaseRead the restore behavior in the dashboard before touching code
Error about a receipt in usereceiptAlreadyInUseError or receiptInUseByOtherSubscriberErrorThe receipt belongs to a different App User ID and the project blocks transfersSign the customer into the account that owns it, or change the behavior
Prompt appeared and nothing happenedpurchaseCancelledErrorThe customer dismissed the OS sheetTreat 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.

BehaviorDocumented effectSupport 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 accessA second account can take access from the first, quietly
Transfer if there are no active subscriptionsTransfers unless the purchase contains an active subscriptionActive subscribers keep access; one-time purchases still move
Keep with original App User IDReturns an error when a different App User ID tries to restore the original purchaser's transactionA 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 onlyAvailable 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 slate slab in daylight carrying two separate connections to one central round brass socket: on the left a keyed brass token mounted on the slab with a short braided brown cord running to the socket, and from the upper right a teal sleeved cord entering through a collar fitting on the slab's raised edge, with a small spoked wheel mounted beside that collar.

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.

  1. Same store account, same app account. Buy in sandbox, reinstall, sign into the same app account, restore. Expect an active entitlement.
  2. 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.
  3. Expired purchase. Let a sandbox subscription lapse, then restore. Expect a returned CustomerInfo with no active entitlement, and confirm your copy does not call that a failure.
  4. Cancelled prompt. Trigger restore and dismiss the OS sheet. Expect purchaseCancelledError and no visible state change.
  5. 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.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Why does RevenueCat restore purchases return nothing in Flutter?

Because the store account signed in on that device owns no current purchase for the entitlement you checked. Purchases.restorePurchases() completes and returns a CustomerInfo, so an empty result is an answer rather than a failure. RevenueCat describes restore as reactivating content purchased from the same store account, which means your first check is which Apple or Google account is signed in now, not your own user table. The other common cause is an expired subscription: the receipt is still there, but no entitlement is active.

Which RevenueCat transfer policy should a Flutter app use?

Match it to whether your product requires an account before purchase. The setting is one project-wide dropdown at Project settings > General, and the four documented options are Transfer to new App User ID (the default), Transfer if there are no active subscriptions, Keep with original App User ID, and the legacy Share between App User IDs. RevenueCat's restore behavior page recommends a transfer behavior for most apps and marks Keep with original App User ID as one to use with caution, since it returns an error instead of moving access. A sandbox override lets you test the choice without changing production.

Can restorePurchases recover a Stripe web subscription?

No. A purchase made in a browser was never written to the device's store receipt, so the native restore call has nothing to find. RevenueCat's documented path is a redemption link that opens your app through a custom URL scheme, parsed with Purchases.parseAsWebPurchaseRedemption and completed with Purchases.redeemWebPurchase. The redemption link documentation requires purchases_flutter 8.4.0 or later and notes the link fails if the app is not installed, if it is opened in a desktop browser, or if it is older than 60 minutes. Signing the customer into the App User ID that holds the purchase is the other route.

Should a Flutter app call restorePurchases on launch?

No, keep it behind a button the customer taps. RevenueCat states that the method should not be triggered programmatically, since it may cause OS level sign-in prompts to appear. An app that restores on every cold start shows a system sign-in sheet to people who only opened a list screen. For syncing without user interaction the documented method is syncPurchases, which does not raise those prompts. Apple's guidelines separately say you should provide a restore mechanism for restorable in-app purchases, so the button itself still belongs in your settings or paywall.

What does the receipt already in use error mean during a restore?

It means the receipt belongs to a different App User ID and your project is configured not to move it. RevenueCat's error reference describes RECEIPT_ALREADY_IN_USE as an identical receipt already in use by another subscriber, with the resolution being to sign in as the App User ID that owns it or to restore, and it recommends a transfer behavior for most apps. In Flutter you read it by passing the thrown PlatformException to PurchasesErrorHelper.getErrorCode and matching PurchasesErrorCode.receiptAlreadyInUseError. Treat it as a sign-in problem to solve in your UI, not an SDK defect.