---
title: "Flutter in-app purchases and subscriptions with purchases_flutter"
description: "Add in-app purchases to a Flutter app on iOS and Android with purchases_flutter and a RevenueDot backend: setProxyURL, offerings, purchase, restore and testing."
url: https://revenuedot.app/blog/flutter-in-app-purchases-tutorial
---

# Flutter in-app purchases and subscriptions with purchases_flutter

To add in-app purchases to a Flutter app, install `purchases_flutter`, call `await Purchases.setProxyURL('https://api.revenuedot.app')` before `Purchases.configure`, then load offerings, call `Purchases.purchase` and check `customerInfo.entitlements.active`. RevenueDot is the backend: it verifies each App Store and Google Play purchase and answers with the customer's entitlements. The same Dart code runs on iOS and Android.

![Architecture: a Flutter app using purchases_flutter talks to RevenueDot, which talks to the App Store and Google Play](https://revenuedot.app/blog/assets/flutter-in-app-purchases-tutorial/cover.svg)

## What you need

- Flutter 3.22 or later. RevenueCat's [Flutter installation guide](https://www.revenuecat.com/docs/getting-started/installation/flutter) lists it as the minimum, and says an iOS deployment target of 13.0 or higher must be set in `ios/Podfile` when you use CocoaPods.
- An App Store Connect app with a subscription, and a Google Play Console app with a subscription. You can start with one platform.
- A RevenueDot Cloud project. [Sign up free](https://app.revenuedot.app/signup).

The steps: create the products, connect the stores, build the catalog, install the package, configure it, build the paywall, test.

## Step 1: Create the subscription in each store

Create the same plan on each platform you ship.

- **App Store:** in App Store Connect, create a subscription group and an auto-renewable subscription with a Product ID such as `pro_monthly`. Apple's [help page](https://developer.apple.com/help/app-store-connect/manage-subscriptions/offer-auto-renewable-subscriptions) lists each field.
- **Google Play:** in Play Console, create a subscription with a Product ID, add a **base plan** with a billing period, set prices, and click **Activate**. Google's [help page](https://support.google.com/googleplay/android-developer/answer/140504) explains that hierarchy: subscription, then base plan, then pricing.

Write down both IDs. For Google Play subscriptions, RevenueDot's product ID is `subscriptionId:basePlanId`, for example `pro:monthly`.

For step-by-step store screens, see [How to add subscriptions to a SwiftUI app](https://revenuedot.app/blog/swiftui-subscriptions-tutorial) and [Android with Google Play Billing](https://revenuedot.app/blog/android-google-play-billing-subscriptions).

## Step 2: Connect the stores to RevenueDot

In RevenueDot, add one app per store.

| Store | What RevenueDot needs | Guide |
|---|---|---|
| App Store | Bundle ID, an In-App Purchase key (`.p8`, Key ID, Issuer ID), and the notification URL set as Version 2 in App Store Connect | [App Store](https://revenuedot.app/docs/guides/app-store) |
| Google Play | Package name, a service account JSON key with Play Console access, and a Pub/Sub push subscription for real-time developer notifications | [Google Play](https://revenuedot.app/docs/guides/google-play) |

The dashboard checks each credential and shows whether notifications arrive.

## Step 3: Create the product, entitlement and offering

1. **Products:** add `pro_monthly` to the App Store app and `pro:monthly` to the Google Play app.
2. **Entitlements:** add `pro`. Attach both products to it.
3. **Offerings:** create `default`, add a `$rc_monthly` package, put both products in it (one per app), and make the offering current.

A package holds one product per app, so one offering serves both platforms. See [offerings and packages](https://revenuedot.app/docs/concepts/offerings-and-packages).

![RevenueDot dashboard page listing an offering with its packages and attached products](https://revenuedot.app/blog/assets/flutter-in-app-purchases-tutorial/offerings.png)

## Step 4: Install the package

```bash
flutter pub add purchases_flutter
```

Then confirm the iOS target in `ios/Podfile`:

```ruby
platform :ios, '13.0'
```

On Android, RevenueCat says to set the Activity's `launchMode` to `standard` or `singleTop` so that a purchase is not cancelled when the customer must authenticate in another app. Check `android/app/src/main/AndroidManifest.xml`.

The RevenueDot [fork](https://revenuedot.app/docs/sdks/flutter) keeps the package name `purchases_flutter`. It is not published yet, and its patch branch needs native packages that are not published, so use the stock package in proxy mode today.

## Step 5: Configure the SDK with the proxy URL

Await `setProxyURL` before `configure`. The stock package defaults `entitlementVerificationMode` to disabled, which is what RevenueDot needs, so you change nothing else.

```dart
import 'dart:io' show Platform;
import 'package:purchases_flutter/purchases_flutter.dart';

Future<void> initPurchases() async {
  // Point the SDK at RevenueDot. Await it before configure.
  await Purchases.setProxyURL('https://api.revenuedot.app');
  await Purchases.configure(
    PurchasesConfiguration(Platform.isIOS ? 'appl_YourKey' : 'goog_YourKey'),
  );
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await initPurchases();
  runApp(const MyApp());
}
```

![Five calls in order: setProxyURL, configure, getOfferings, purchase, then check entitlements](https://revenuedot.app/blog/assets/flutter-in-app-purchases-tutorial/startup-order.svg)

Never set `EntitlementVerificationMode.enforced` with the stock package. It checks signatures against RevenueCat's key, so every request would fail. See [Trusted Entitlements](https://revenuedot.app/docs/guides/trusted-entitlements).

**Flutter web:** the stock package's web plugin ignores `setProxyURL`, so web calls still go to RevenueCat. The RevenueDot fork fixes this, but it is not published. Target iOS and Android for now.

## Step 6: Build the paywall

Load the current offering, show its packages, buy, restore and read the entitlement.

```dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show PlatformException;
import 'package:purchases_flutter/purchases_flutter.dart';

class PaywallScreen extends StatefulWidget {
  const PaywallScreen({super.key});
  @override
  State<PaywallScreen> createState() => _PaywallScreenState();
}

class _PaywallScreenState extends State<PaywallScreen> {
  List<Package> packages = [];
  bool isPro = false;
  String? error;

  @override
  void initState() {
    super.initState();
    Purchases.addCustomerInfoUpdateListener(_apply);
    _load();
  }

  void _apply(CustomerInfo info) {
    if (!mounted) return;
    setState(() => isPro = info.entitlements.active.containsKey('pro'));
  }

  Future<void> _load() async {
    try {
      final offerings = await Purchases.getOfferings();
      _apply(await Purchases.getCustomerInfo());
      setState(() => packages = offerings.current?.availablePackages ?? []);
    } on PlatformException catch (e) {
      setState(() => error = e.message);
    }
  }

  Future<void> _buy(Package package) async {
    try {
      final result = await Purchases.purchase(PurchaseParams.package(package));
      _apply(result.customerInfo);
    } on PlatformException catch (e) {
      final code = PurchasesErrorHelper.getErrorCode(e);
      if (code != PurchasesErrorCode.purchaseCancelledError) {
        setState(() => error = e.message);
      }
    }
  }

  Future<void> _restore() async => _apply(await Purchases.restorePurchases());

  @override
  Widget build(BuildContext context) {
    if (isPro) return const Center(child: Text('You have Pro'));
    return ListView(padding: const EdgeInsets.all(16), children: [
      for (final p in packages)
        FilledButton(
          onPressed: () => _buy(p),
          child: Text('${p.storeProduct.title}, ${p.storeProduct.priceString}'),
        ),
      TextButton(onPressed: _restore, child: const Text('Restore purchases')),
      if (error != null) Text(error!, style: const TextStyle(color: Colors.red)),
    ]);
  }
}
```

`Purchases.purchasePackage` still works but is deprecated in the 10.x line. The `PurchaseParams.package` form is the current one.

**Expected output:** the screen lists one button per package. After a sandbox purchase, `isPro` turns true, and the dashboard shows the customer with an active `pro` entitlement and an `INITIAL_PURCHASE` event.

## Optional: identify your own users

Without an app user ID, the SDK starts with an anonymous ID (it begins with `$RCAnonymousID:`) and keeps it on the device. That is fine for an app with no accounts. If your app has sign-in, call `logIn` after the customer signs in, so their purchases follow them to a new phone and to your web app.

```dart
// After your own sign-in succeeds:
final result = await Purchases.logIn(myUserId);
final isPro = result.customerInfo.entitlements.active.containsKey('pro');

// When the customer signs out:
await Purchases.logOut();
```

When an anonymous customer who already bought something calls `logIn`, their purchases move to the identified user. RevenueDot keeps one customer record per app user ID and lists the old anonymous ID as an alias. The rules for who owns a purchase that two accounts both restore are the project's transfer behavior. They are explained in [customers and app user IDs](https://revenuedot.app/docs/concepts/customers-and-app-user-ids) and the [restore guide](https://revenuedot.app/docs/help/restore-purchases).

Use the same user ID on every platform, and never put a secret in it. The ID appears in dashboards, webhooks and logs.

## Optional: move an existing RevenueCat app to RevenueDot

If your Flutter app already ships `purchases_flutter` with RevenueCat's backend, the change is three lines. Add the proxy URL before `configure`, keep your current keys if you ran the [importer](https://revenuedot.app/docs/migrate/importer), and call `syncPurchases` once on the first launch of the update, so subscribers who bought while the app talked to RevenueCat keep access.

```dart
await Purchases.setProxyURL('https://api.revenuedot.app');
await Purchases.configure(PurchasesConfiguration(Platform.isIOS ? 'appl_YourKey' : 'goog_YourKey'));
// Once, after this update:
await Purchases.syncPurchases();
```

Old app versions keep calling RevenueCat until their owners update, so run both systems side by side for a while. The [migration post](https://revenuedot.app/blog/migrating-from-revenuecat-without-data-loss) gives the order of steps.

## Step 7: Test

**With no store account.** Create a **Test Store** app in RevenueDot, add a product and a Test Store price, and pass its `test_` key to `configure`. The SDK shows a Test Store dialog. Tap **Test valid purchase**. Test Store keys work only in debug builds, so ship with the `appl_` and `goog_` keys. For a local server, the iOS simulator reaches your Mac at `localhost` and the Android emulator at `10.0.2.2`.

**On iOS.** Create a sandbox tester under **Users and Access, Sandbox** in App Store Connect and sign in with it under **Settings, App Store, Sandbox Account**. Sandbox subscriptions renew in minutes.

**On Android.** Add license testers in Play Console under **Setup, License testing**, publish the app to an internal test track, and install from that track. Test subscriptions renew every few minutes.

Before you ship, run one sandbox purchase on each store and check that it reaches the customer page and your webhooks. See [sandbox testing](https://revenuedot.app/docs/guides/sandbox-testing).

## Common errors and fixes

| Symptom | Cause | Fix |
|---|---|---|
| Offerings are empty | Product IDs differ between the store and RevenueDot, or the offering is not current | Match the IDs, make the offering current |
| Web calls reach RevenueCat | The stock web plugin ignores `setProxyURL` | Use iOS and Android, or the fork when it is published |
| Every request fails with a signature error | `entitlementVerificationMode` set to `enforced` | Remove it. Disabled is the default |
| Purchase succeeds on Android, entitlement missing | No service account on the Google Play app | Add it. RevenueDot answers 503 (code 7101) until then and the SDK retries |
| iOS build fails after you add the fork | The fork needs unpublished native packages | Use the stock package in proxy mode |

## Do it with RevenueDot

1. [Create a free account](https://app.revenuedot.app/signup). Cloud is free up to $10,000 in monthly tracked revenue.
2. Add your App Store and Google Play apps with their credentials.
3. Create the product, the `pro` entitlement and the `default` offering.
4. Add `await Purchases.setProxyURL('https://api.revenuedot.app')` before `configure`.
5. Add a webhook under **Integrations, Webhooks** so your own backend hears about purchases.

[Start free on RevenueDot Cloud](https://app.revenuedot.app/signup)

## FAQ

### Which Flutter package do I use for subscriptions?

Use `purchases_flutter`, the RevenueCat SDK package. It works with RevenueCat's own backend and with RevenueDot when you call `Purchases.setProxyURL` before `configure`.

### Does purchases_flutter work with a backend other than RevenueCat?

Yes, through the proxy URL. RevenueDot speaks the same API, so the SDK talks to `https://api.revenuedot.app` or to your own server and your Dart code does not change.

### Do I need the in_app_purchase package as well?

No. `purchases_flutter` wraps StoreKit and Google Play Billing for you, and it handles verification through the backend. Use one purchase package, not two.

### Can I use one entitlement for iOS and Android?

Yes. Attach the App Store product and the Google Play product to the same entitlement, and put both in one package. Your app checks `entitlements.active['pro']` on both platforms.

### Does it work on Flutter web?

Not in proxy mode with the stock package, because its web plugin ignores the proxy URL. RevenueDot's fork fixes it, but the fork is not published yet.

**About RevenueDot.** RevenueDot is an open-source (AGPL-3.0) backend for in-app purchases and subscriptions that works with the RevenueCat SDK. Start free on [RevenueDot Cloud](https://app.revenuedot.app/signup), free up to $10,000 in monthly tracked revenue, or self-host it with Docker and Postgres. Point the SDK's proxy URL at RevenueDot and keep your app code, your offerings and your customers. Read the [quickstart](https://revenuedot.app/docs/getting-started/quickstart.md) or the code on [GitHub](https://github.com/revenuedot/revenuedot).
