Skip to content
SaaS Development28 October 2026 · 11 min read

Handle Pending and Cancelled Flutter Purchases

A Flutter paywall shows an error when the user cancels, or unlocks premium while a purchase still waits for approval. Model five purchase outcomes and unlock only from an active RevenueCat entitlement.

Handle Pending and Cancelled Flutter Purchases

What you'll learn

  • What can one tap on Buy return?
  • Where pending purchases come from
  • Model the outcomes as states
Article contents

The user taps Subscribe, changes their mind on the store sheet, and your paywall shows a red "Purchase failed" dialog. Another user pays with a method that has to clear later, or a child sends an Ask to Buy request to a parent, and the app either reports an error or unlocks premium straight away. Both apps treat every purchase outcome as success or failure, and the store has more answers than that.

The short answer is to keep five outcomes apart: cancelled, pending, completed, failed and unresolved. Only an active RevenueCat entitlement unlocks paid features, and a long-lived CustomerInfo listener, rather than the paywall screen, is what notices when a pending purchase completes later.

The demo app in this guide is synthetic: one entitlement called pro and a monthly package. Store and SDK behavior was checked against current RevenueCat, Google Play and Apple documentation on 28 October 2026. The Dart snippets follow the RevenueCat Flutter examples but have not been compiled or run for this article. Version 11.0.0 of purchases_flutter appeared on pub.dev that day with Android build changes, so read its migration notes before upgrading, and use the acceptance checks at the end to prove each state in your own project.

What can one tap on Buy return?

One call to Purchases.purchase can end in five different situations, and only one of them means the user should have access right now.

OutcomeHow it reaches your codeWhat the user sees
CancelledPlatformException with purchaseCancelledErrorBack on the paywall, no message
PendingPlatformException with paymentPendingErrorWaiting for approval or payment
CompletedPurchaseResult whose CustomerInfo has an active entitlementPremium unlocked
FailedAnother error code, such as purchaseNotAllowedErrorA specific message and a way back
UnresolvedstoreProblemError, or a network error after the store sheetChecking status, no charge assumed either way

The RevenueCat error guide describes PURCHASE_CANCELLED as a user who cancelled and was not charged, and says no action is required. It adds that on iOS the same code can mean the Apple account already owns the product, which is why the paywall should keep its Restore button visible. The same guide defines PAYMENT_PENDING_ERROR as additional action required by the user before an entitlement is granted, on both Apple and Google, and recommends confirming to the user that they have started the pending purchase.

The unresolved row comes from the same page. It says to assume the user was not charged unless a store problem error occurred, in which case they may or may not have been charged, and that RevenueCat retries purchase failures automatically. Treat that as "we don't know yet" and go and check, rather than as a failure that invites another payment.

Where pending purchases come from

Google Play calls these pending transactions: purchases with extra steps between the user starting a purchase and the payment method being charged. Its documentation says to grant entitlement only when the purchase state is PURCHASED, and to tell the user they still need to complete actions while it is PENDING. Pending support is required for one-time products, and with Play Billing Library 7.0 or later it can also be enabled for subscription prepaid plans.

On Apple platforms, the common source is Ask to Buy, where a child's purchase request goes to a parent or guardian. StoreKit reports this as a pending purchase result, and if it later succeeds, StoreKit delivers the transaction through Transaction.updates. RevenueCat wraps both stores in the same error code. Its Android one-time products guide states that RevenueCat does not grant entitlements for pending purchases and that its backend detects when the payment completes.

Model the outcomes as states

Give the paywall its own state type, so cancellation and pending are ordinary values and not exceptions the UI has to interpret.

sealed class PaywallState {
  const PaywallState();
}

class Idle extends PaywallState { const Idle(); }
class Purchasing extends PaywallState { const Purchasing(); }
class AwaitingApproval extends PaywallState { const AwaitingApproval(); }
class Unlocked extends PaywallState { const Unlocked(); }
class CheckingStatus extends PaywallState { const CheckingStatus(); }

class PurchaseFailed extends PaywallState {
  const PurchaseFailed(this.message);
  final String message;
}

There is no Cancelled class on purpose. A cancelled purchase returns the paywall to Idle, which is exactly where the user chose to be.

State diagram of the demo paywall. Idle moves to Purchasing when the user taps Buy. Purchasing returns to Idle when the user cancels, moves to Unlocked when the entitlement is active, to Awaiting approval on a pending payment, to Failed on a store or SDK error, and to Checking status when the outcome is unknown. Awaiting approval and Checking status both reach Unlocked when the CustomerInfo listener reports an active entitlement. Failed returns to Idle through Try again.

Notice that two arrows enter Unlocked from states where the original purchase call has already returned. Those transitions are driven by the entitlement listener described below, not by the code that ran when the user tapped Buy.

Map RevenueCat errors to those states

Purchase errors arrive as a PlatformException. PurchasesErrorHelper.getErrorCode converts it to a PurchasesErrorCode, and the making purchases guide uses the same pattern to skip the error dialog on cancellation.

const entitlementId = 'pro';

Future<PaywallState> buy(Package package) async {
  try {
    final result = await Purchases.purchase(PurchaseParams.package(package));
    final active =
        result.customerInfo.entitlements.all[entitlementId]?.isActive ?? false;
    return active ? const Unlocked() : const CheckingStatus();
  } on PlatformException catch (e) {
    switch (PurchasesErrorHelper.getErrorCode(e)) {
      case PurchasesErrorCode.purchaseCancelledError:
        return const Idle();
      case PurchasesErrorCode.paymentPendingError:
        return const AwaitingApproval();
      case PurchasesErrorCode.storeProblemError:
      case PurchasesErrorCode.networkError:
      case PurchasesErrorCode.operationAlreadyInProgressError:
        return const CheckingStatus();
      case PurchasesErrorCode.purchaseNotAllowedError:
        return const PurchaseFailed('Purchases are not allowed on this device.');
      default:
        return const PurchaseFailed('The purchase did not go through.');
    }
  }
}

A completed call without an active pro entitlement goes to CheckingStatus rather than Unlocked. That covers a misconfigured product that is not attached to the entitlement, which you want to see in testing instead of shipping an unlock that the server will never confirm. operationAlreadyInProgressError is in the checking group because the error guide says an identical operation is already running and you should wait for it to finish.

PurchaseFailed needs more specific copy in a real app. The 11.0.0 changelog notes that a blocked Play Store now produces purchaseNotAllowedError instead of storeProblemError, which is one reason to keep this mapping in a single function that you can review when you upgrade the SDK.

Keep the button responsive without double submissions

Disable the Buy button only while a purchase call is actually in flight, and show progress on the button itself. When the call returns, the state decides what the user can do next.

Future<void> onBuyPressed(Package package) async {
  if (state is Purchasing) return;
  setState(() => state = const Purchasing());
  final next = await buy(package);
  if (!mounted) return;
  setState(() => state = next);
}

The mounted check matters because the user can leave the paywall while the store sheet is open, and the outcome must not depend on that screen still existing. In AwaitingApproval, replace the Buy button with a status message and a Check status action that calls Purchases.getCustomerInfo(), so a waiting user is not invited to start a second purchase of the same product. Keep Restore Purchases available on every state. The restoring purchases guide says restorePurchases should only run from a user action such as that button, because it can trigger operating system sign-in prompts.

Recover when the store finishes later

A pending purchase can complete after the paywall has closed, after the app has been killed, or on another day. Put entitlement state in an object that lives as long as the app and listens for CustomerInfo changes.

class EntitlementController extends ValueNotifier<bool> {
  EntitlementController() : super(false) {
    Purchases.addCustomerInfoUpdateListener(_onInfo);
  }

  void _onInfo(CustomerInfo info) {
    value = info.entitlements.all[entitlementId]?.isActive ?? false;
  }

  @override
  void dispose() {
    Purchases.removeCustomerInfoUpdateListener(_onInfo);
    super.dispose();
  }
}

Create it once after Purchases.configure, and have every paid feature read value instead of remembering what happened on the paywall. The addCustomerInfoUpdateListener reference says the function is called right away with the latest customer info, which gives a restarted app its current answer without a special code path.

An old wooden post office sorting cabinet with rows of empty pigeonholes in shadow. One cubby is lit by warm copper light and holds a single sealed envelope.

The completion arrives at a fixed address in the app, whichever screen happens to be open. The paywall can listen to the same controller and move from AwaitingApproval to Unlocked while it is visible, but it is never the only place that hears about it.

There is one timing limit to design for. The CustomerInfo documentation says updates are not pushed from the RevenueCat backend to the app. The SDK refreshes CustomerInfo when the app becomes active, and refreshes a cache older than five minutes when you call getCustomerInfo(), purchase or restore. Depending on the store, a purchase approved while the app sits open may therefore not unlock until the SDK makes its next request, for example when the user returns to the app or taps Check status. For features enforced on your server, use RevenueCat webhooks or your own entitlement records, as described in the subscription lifecycle states guide.

Write pending copy that gives the user a way back

Pending is not a failure, so the message should say what happens next and what the user can do. For the demo app:

  • Title: Purchase waiting for approval
  • Body: Your purchase was started, but the store still needs a payment or an approval. Premium unlocks automatically when the store confirms it. You can keep using the free version until then.
  • Primary action: Check status
  • Secondary action: Continue with free

Avoid copy that promises a time, because the app cannot know how long a parent or a payment method will take, and it cannot make the store finish the transaction. Google Play may also cancel a pending purchase: its real-time notifications include SUBSCRIPTION_PENDING_PURCHASE_CANCELED, so your server should not treat pending as a promise of revenue either.

Run the acceptance checks

Use store test accounts with no previous purchases. The existing RevenueCat paywall guide covers the initial SDK and entitlement setup these checks assume.

StoreTool for pending purchasesNote
Google PlayLicense tester with "Slow test card, approves after a few minutes" and "Slow test card, declines after a few minutes"Listed in Google's billing test guide
AppleStoreKit configuration file with Ask to Buy enabled, approved or declined in the Xcode transaction managerRevenueCat needs the configuration's public certificate, and its sandbox guide says flutter run ignores the scheme's StoreKit file, so launch from Xcode
  1. User cancellation. Open the store sheet and dismiss it. The paywall returns to Idle with no dialog, and no entitlement appears.
  2. Pending purchase. Buy with the slow approving card or Ask to Buy. The app shows the waiting message, the Buy button is gone, and paid features stay locked.
  3. Delayed completion. Wait for the slow card, or approve in Xcode, then bring the app to the foreground. The controller reports pro as active without visiting the paywall again.
  4. Declined pending purchase. Repeat with the declining card or decline in Xcode, restart the app, and confirm pro is still inactive.
  5. App restart while pending. Kill the app after step 2 and reopen it. Paid features remain locked until completion, then unlock on the next foreground.
  6. Transient SDK error. Turn on airplane mode after the store sheet confirms. If the SDK reports a network or store problem, the paywall shows Checking status rather than a failure, and turning the network back on followed by Check status reaches the state the store reports.

Log the state transitions in debug builds so a failing check tells you which arrow in the diagram was taken.

What this design does not cover

The app reflects what the store and RevenueCat report. It cannot approve an Ask to Buy request, speed up a delayed payment, or recover a purchase the store declined. Offline entitlements are also limited: the CustomerInfo documentation says they do not currently work for one-time purchases, which return an error instead. If your paywall sells a lifetime product, test the unresolved path for it separately.

Start by searching your paywall code for every place that unlocks a feature. Each one should read the entitlement controller, and any that reacts to the purchase call returning is where the next pending purchase will be unlocked too early.

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

How do I detect a cancelled purchase in RevenueCat Flutter?

Catch the PlatformException from Purchases.purchase and check whether PurchasesErrorHelper.getErrorCode(e) returns PurchasesErrorCode.purchaseCancelledError. The RevenueCat error guide says this means the user cancelled and was not charged, so return the paywall to its idle state without an error dialog. On iOS the same code can mean the account already owns the product, so keep Restore Purchases visible.

What does paymentPendingError mean in RevenueCat?

It means the store needs more action from the user, such as an Ask to Buy approval or a delayed payment, before an entitlement is granted. The RevenueCat error guide lists it for Apple and Google and recommends telling the user they have started a pending purchase. Show a waiting state, keep paid features locked, and let a CustomerInfo listener unlock them when the entitlement becomes active.

Should a pending purchase unlock premium features?

No, keep features locked until the entitlement is active. Google Play's billing guide says to grant entitlement only when the purchase state is PURCHASED, and RevenueCat does not grant entitlements for pending purchases. Gate features on customerInfo.entitlements.all['pro']?.isActive from an app-wide controller instead of on the result of the purchase call.

How does the app find out when a pending purchase completes?

Register Purchases.addCustomerInfoUpdateListener in an object that lives as long as the app, not inside the paywall screen. The purchases_flutter reference says the listener is called right away with the latest customer info. RevenueCat does not push updates to the app, so the change usually appears when the SDK next refreshes, for example when the app becomes active or the user taps Check status.