Introduction

I have a Flutter app and business (Stash Hub) that is available on Android, iOS and web. Currently I use Revenue Cat for managing subscriptions on the mobile platforms and I’m very happy with it. However, I often have users saying they don’t want to pay on the mobile app (either they don’t have Google/Apple payments set up or don’t know how). Revenue Cat does support web payments through Stripe but there’s still a fair bit of dev work to get it set up and more importantly Stripe doesn’t handle the tax side of things. Stripe does have Stripe Tax to make it a bit easier but it’s still not as simple as the App Stores make it. What I’m after is a Merchant of Record (MoR) that can handle the tax side of things, refunds, charge-backs and user management, and I just receive the money into my business bank account.

Having looked around, I found Paddle which is a Merchant of Record that ticked all my boxes:

  • Good documentation
  • Specialised in subscriptions
  • Handles tax
  • A customer portal (for users to manage their subscriptions)
  • Supports common payment methods (e.g. PayPal, Apple Pay, Google Pay)

The only challenge? Paddle doesn’t have an official Flutter SDK, so I had to build the integration myself using Dart’s JavaScript interop. Additionally, I needed a way to sync subscription status across platforms—ensuring that web purchases reflect on mobile and vice versa.

⚠️ Warning: There is quite a lot code in this post, although I’ve done my best to omit unnecessary details (e.g. logging).

Paddle Screenshot

Paddle Integration

Source of truth?

The biggest question I had before this was what was my source of truth going to be? Before, Revenue Cat was my single source of truth so I considered syncing Paddle subscriptions with Revenue Cat. However, I decided against this because it would mean creating a webhook and processing every single type of event and hoping there was a 1:1 mapping between Paddle and Revenue Cat events. Instead, I decided to use both Paddle and Revenue Cat as my sources of truth and just check the status of each subscription on both platforms.

While this might not be the most efficient solution, it ensures that the subscription status is always up-to-date across both platforms with much less effort on my part.

Cloud Functions

We all know we shouldn’t store sensitive API keys in our client apps so I created a couple of (Firebase) cloud functions to handle making requests to Paddle’s API. These two functions are responsible for:

  1. Getting the available subscription packages.
  2. Getting the subscription status of a user.

Note: I use Python for my cloud functions but the same would work with JavaScript. Paddle has server SDKs (API wrappers) for both.

Tip: I like to have as little logic inside the callable function as possible. This makes it easier to test locally (code below).

1. Getting the available subscription packages

This is just a matter of making a request to Paddle’s API and transforming the list of packages into a format I want (i.e. a list of SubscriptionPackage objects) and returning it. There are a few nuances:

  • The pricing is returned in GBP (or whatever you configure in Paddle) but I want to show it in the user’s local currency. Fortunately Paddle has a way of doing this by providing a user’s IP address or country code. In my version I get the IP address from the request headers but also pass in the user’s country code should that not be available.
  • Discounts need to be fetched separately and applied to the subscription packages
  • I get the currency symbol by splitting the formatted price string. This might not work for all currencies but I did test a few where the symbol is normally placed after the number but it seems like Paddle always returns the symbol before the number.
@https_fn.on_call(region="europe-west1")
def subscription_packages(req: https_fn.CallableRequest) -> Any:
    try:
        user_ip = get_user_ip(req)
        data = req.data
        country_code = data.get("countryCode")
        is_prod = os.getenv("GCLOUD_PROJECT") == "stash-hub"
        packages = get_subscription_packages(
            Environment.PRODUCTION if is_prod else Environment.SANDBOX,
            user_ip,
            country_code,
        )
        logging.info(f"Subscription packages: {packages}")
        return packages
    except:
        # Error handling...

def get_user_ip(request: https_fn.CallableRequest) -> str | None:
    x_forwarded_for = request.raw_request.headers.get("X-Forwarded-For", "")
    try:
        user_ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else None
        return user_ip
    except Exception as err:
        return None

def get_subscription_packages(
    environment: Environment,
    ip_address: str | None,
    country_code: str | None,
) -> list[dict[str, Any]]:
    paddle = Client(
        key,
        options=Options(environment),
    )
    packages: list[dict[str, Any]] = []
    active_discounts = list(
        paddle.discounts.list(
            operation=ListDiscounts(
                statuses=[Status.Active],
            ),
        )
    )

    def discount_filter(discount: Discount) -> bool:
        is_percentage = discount.type == DiscountType.Percentage
        has_expired = (
            discount.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc)
            if discount.expires_at
            else False
        )
        has_code = discount.code is not None
        return is_percentage and not has_expired and not has_code

    desired_discounts = list(filter(discount_filter, active_discounts))
    prices = paddle.prices.list()
    for price in prices:
        preview_price = PreviewPrice(
            items=[
                PricePreviewItem(
                    price_id=price.id,
                    quantity=1,
                ),
            ],
            customer_ip_address=ip_address,
            address=AddressPreview(
                country_code=CountryCode(country_code.upper() if country_code else "US"),
                postal_code=None,
            )
            if not ip_address
            else None,
        )
        pricing_preview = paddle.pricing_previews.preview_prices(preview_price)
        unformatted_total_price = pricing_preview.details.line_items[0].totals.total
        price_value = float(unformatted_total_price)
        formatted_total_price = pricing_preview.details.line_items[0].formatted_totals.total
        currency = formatted_total_price[0]
        billing_cycle = pricing_preview.details.line_items[0].price.billing_cycle
        duration = "unknown"
        if billing_cycle:
            duration = "oneYear" if str(billing_cycle.interval) == "year" else "oneMonth"
        discount_code = None
        discount_percentage = None
        if desired_discounts:
            discount = desired_discounts[0]
            discount_code = discount.id
            discount_percentage = float(discount.amount)
        data = {
            "duration": duration,  # "oneMonth", "oneYear", "unknown"
            "subscriptionType": "premium",
            "identifier": price.id,
            "name": price.name,
            "price": price_value,
            "discountedPrice": price_value - price_value * (discount_percentage / 100.0)
            if discount_percentage
            else None,
            "discountCode": discount_code,
            "currencyCode": currency,
        }
        packages.append(data)
    return packages

if __name__ == "__main__":
    packages = get_subscription_packages(Environment.SANDBOX, None, None)
    print(packages)

2. Getting the subscription status of a user

I had already implemented this function for getting the Revenue Cat subscription status for users on the web, so it just needed updating to also check the paddle subscription status. One thing to note is that Paddle requires a customer ID to be passed in to get the subscription status, which I store in the user’s document during checkout (more on that later).

class Subscription:
    def __init__(
        self,
        expiration: str,
        is_gifted: bool,
        will_renew: bool,
        management_url: str | None,
    ):
        self.expiration = expiration
        self.is_gifted = is_gifted
        self.will_renew = will_renew
        self.management_url = management_url

    def to_dict(self) -> dict[str, Any]:
        return {
            "expirationDate": self.expiration,
            "willRenew": self.will_renew,
            "isGifted": self.is_gifted,
            "managementUrl": self.management_url,
        }

@https_fn.on_call(
    region="europe-west1",
    memory=options.MemoryOption.MB_512,
    min_instances=1,
)
def get_subscription_status(req: https_fn.CallableRequest) -> Any:
    try:
        user_id = req.auth.uid
        # Get Paddle customer ID
        paddle_id = get_paddle_customer_id(user_id)
        logging.info("Paddle customer ID: %s", paddle_id)

        # If Paddle customer ID exists, try to get Paddle subscription
        if paddle_id:
            subscription = get_paddle_subscription(paddle_id)
            if subscription:
                return subscription.to_dict()

        # Otherwise, try to get the RevenueCat subscription
        subscription = get_revenue_cat_subscription(user_id)
        if subscription:
            return subscription.to_dict()

        # No active subscription found
        return None
    except:
        # Error handling

def get_paddle_customer_id(user_id: str) -> str | None:
    doc = db.collection("users").document(user_id).get()
    data = doc.to_dict()
    if data is None:
        return None
    paddle_customer_id: str | None = data.get("paddleCustomerId")
    return paddle_customer_id

def get_paddle_subscription(paddle_customer_id: str) -> Subscription | None:
    is_prod = os.getenv("GCLOUD_PROJECT") == "stash-hub"
    paddle_api_key = os.getenv("PADDLE_KEY") if is_prod else os.getenv("PADDLE_SANDBOX_KEY")
    if not paddle_api_key:
        raise ValueError("Paddle API key is not set")

    paddle = Client(
        paddle_api_key,
        options=Options(Environment.PRODUCTION if is_prod else Environment.SANDBOX),
    )

    list_subscriptions = ListSubscriptions(
        customer_ids=[paddle_customer_id],
        statuses=[SubscriptionStatus.Active],
    )
    subscriptions = paddle.subscriptions.list(list_subscriptions)
    if len(subscriptions.items) == 0:
        return None

    subscription = subscriptions.current()
    expires_at = subscription.current_billing_period.ends_at if subscription.current_billing_period else None
    if not expires_at:
        raise ValueError("No expiration date found in Paddle subscription")

    if expires_at < datetime.now(timezone.utc):
        logging.info("Paddle subscription has expired")
        return None

    management_url = subscription.management_urls.update_payment_method if subscription.management_urls else None
    will_renew = subscription.canceled_at is None and subscription.paused_at is None
    active_subscription = Subscription(
        expiration=expires_at.isoformat(),
        is_gifted=False,  # Gifted status is not determined via Paddle at this time.
        will_renew=will_renew,
        management_url=management_url,
    )
    return active_subscription

if __name__ == "__main__":
    try:
        sub = get_paddle_subscription("ctm_abcd...") # Add actual Paddle customer id
        if sub:
            print("Paddle subscription: %s", sub.to_dict())
        else:
            print("No Paddle subscription found")
    except Exception as e:
        print(f"Error retrieving Paddle subscription: {e}")

App

This part was pretty straightforward as I already had an abstract interface for managing subscriptions in the app which I had already implemented for the web (even though all it did at this point was call a cloud function when getting the active subscription). I even had the foresight to create a service agnostic class for a UserSubscription and SubscriptionPackage (“Clean Architecture” paying off).

abstract class SubscriptionService {
  const SubscriptionService();

  Future<void> initService(bool isDebugMode, bool isAndroid, String? userId);
  Future<List<SubscriptionPackage>> fetchSubscriptionPackages({String? countryCode});
  Future<void> purchaseSubscription(SubscriptionPackage package);
  Stream<UserSubscription> getActiveSubscription();
  Future<void> refresh();
  Future<void> restorePurchase();
  Future<void> login(String userId);
  Future<void> logout();
}

Data Models

@freezed
class SubscriptionPackage with _$SubscriptionPackage {
  const SubscriptionPackage._();

  const factory SubscriptionPackage({
    required SubscriptionLength duration,
    required SubscriptionType subscriptionType,
    required String identifier,
    required String name,
    required double price,
    required double? discountedPrice,
    required String currencyCode,

    /// Only relevant for Paddle
    String? discountId,
  }) = _SubscriptionPackage;

  String? discountPercentage() {
    if (discountedPrice == null) {
      return null;
    } else {
      final discount = (1 - (discountedPrice! / price)) * 100;
      return '${discount.toStringAsFixed(0)}%';
    }
  }
}
@freezed
class UserSubscription with _$UserSubscription {
  const factory UserSubscription({
    required bool isSubscribed,
    required DateTime? expirationDate,
    required SubscriptionType? subscriptionType,
    required bool? willRenew,
    required bool isGifted,
    required String? managementUrl,
  }) = _UserSubscription;

  const factory UserSubscription.empty({
    @Default(false) bool isSubscribed,
    DateTime? expirationDate,
    SubscriptionType? subscriptionType,
    bool? willRenew,
    @Default(false) bool isGifted,
    String? managementUrl,
  }) = _UserSubscriptionEmpty;

  const UserSubscription._();

  Duration get timeRemaining => (expirationDate ?? DateTime.now()).difference(DateTime.now());

  SubscriptionPlatform get platform {
    if (managementUrl == null) {
      return SubscriptionPlatform.paddle;
    } else if (managementUrl!.contains('apple')) {
      return SubscriptionPlatform.apple;
    } else if (managementUrl!.contains('play')) {
      return SubscriptionPlatform.google;
    } else {
      return SubscriptionPlatform.paddle;
    }
  }
}

enum SubscriptionPlatform {
  apple,
  google,
  paddle,
}

WebSubscriptionService

In all honesty, I have very little experience with JavaScript and even less experience in implementing it in a Flutter web app (i.e. zero experience), so there was a lot of trial and error involved (and ChatGPT). For the uninitiated, Dart’s JavaScript interoperability allows Dart code to interact with JavaScript code. This is particularly useful for leveraging existing JavaScript libraries or APIs that do not have Dart equivalents (e.g. Paddle).

The key things I learnt:

  • @JS(javascript.global.object) - references a global JS object to a Dart object.
  • dart:js_util is being depreciated in favour of dart:js_interop. Some of my code below uses js_util so I’ll need to update it at some point to use JS types.
  • You can create and pass Dart objects that map to their corresponding JavaScript objects using type aliasing (but this seemed overkill in my case).
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:async';
import 'dart:js_interop';
import 'dart:js_util' as js_util;

@JS('Paddle.Environment.set')
external void setEnvironment(String environment);

@JS('Paddle.Initialize')
external void paddleInitialize(JSObject? options);

@JS('Paddle.Checkout.open')
external void openCheckout(JSObject? options);

@JS('Paddle.Checkout.close')
external void closeCheckout();

class WebSubscriptionService implements SubscriptionService {
  WebSubscriptionService(
    this._firebaseFunctions,
    this._firebaseAuth,
    this._firestore,
    this._subscriptionStatusService,
  );

  final FirebaseFunctions _firebaseFunctions;
  final FirebaseAuth _firebaseAuth;
  final FirebaseFirestore _firestore;
  final SubscriptionStatusService _subscriptionStatusService;

  final StreamController<UserSubscription> _controller = BehaviorSubject();
  Completer<void>? _purchaseCompleter;

  Future<void> _fetchSubscriptionStatus({bool force = false}) async {
    try {
      final subscription = await _subscriptionStatusService.getSubscriptionStatus(force: force);
      assert(subscription != null, 'Subscription status should never be null on web');
      if (subscription != null) {
        _controller.add(subscription);
      }
    } catch (e, st) {
      _controller.addError(e, st);
    }
  }

  @override
  Future<List<SubscriptionPackage>> fetchSubscriptionPackages({String? countryCode}) async {
    try {
      final callable = _firebaseFunctions.httpsCallable('subscription_packages');
      final args = <String, dynamic>{'countryCode': countryCode};
      final result = await callable.call(args);
      final data = result.data as List<dynamic>;
      final packages = data.map((e) {
        return SubscriptionPackage(
          duration: SubscriptionLength.values.firstWhere((element) => element.name == e['duration'] as String),
          subscriptionType: SubscriptionType.premium,
          identifier: e['identifier'] as String,
          name: e['name'] as String,
          price: (e['price'] as double) / 100,
          discountedPrice: (e['discountedPrice'] as double?) != null ? (e['discountedPrice'] as double) / 100 : null,
          currencyCode: e['currencyCode'] as String,
          discountId: e['discountCode'] as String?,
        );
      }).toList();
      return packages;
    } catch (e) {
      log(e.toString());
      rethrow;
    }
  }

  @override
  Stream<UserSubscription> getActiveSubscription() {
    return _controller.stream;
  }

  @override
  Future<void> initService(bool isDebugMode, bool isAndroid, String? userId) async {
    if (isDebugMode) {
      setEnvironment('sandbox');
    } else {
      setEnvironment('production');
    }

    final initMap = {
      'token': isDebugMode ? 'test_token' : 'live_token',
      'eventCallback': js_util.allowInterop((data) async {
        final event = js_util.dartify(data);
        log('Raw event data (as Dart): $event');
        if (event is Map) {
          if (event['name'] != null) {
            final eventName = event['name'] as String;
            log('Event name: $eventName');
            if (eventName == 'checkout.completed') {
              // Probably need to wait for the purchase to propogate
              await Future.delayed(const Duration(seconds: 2));
              await _fetchSubscriptionStatus(force: true);
              _purchaseCompleter?.complete();
            } else if (eventName == 'customer.created' ||
                eventName == 'customer.updated' ||
                eventName == 'checkout.customer.created' ||
                eventName == 'checkout.customer.updated') {
              // This branch occurs when the checkout it opened and the user enters their email
              try {
                // If the user logs in and tries to purchase in the same flow, user id passed into initService will be null
                final appUserId = userId ?? _firebaseAuth.currentUser!.uid;
                final customerId = event['data']['customer']['id'] as String;
                final docRef = _firestore.collection('users').doc(appUserId);
                await docRef.update({'paddleCustomerId': customerId});
                // I want to make sure that the document is updated on the server before continuing
                // (If the user checks out really quickly this doesn't help but it's unlikely)
                final _ = await docRef.get(GetOptions(source: Source.server)).timeout(const Duration(seconds: 10));
              } catch (e, st) {
                log('Error saving Paddle customer ID: $e, $st');
                _purchaseCompleter?.completeError(e, st);
                // Just to make sure the error propogates
                await Future.delayed(const Duration(milliseconds: 100));
                closeCheckout();
              }
            } else if (eventName == 'checkout.closed') {
              if (_purchaseCompleter?.isCompleted == false) {
                _purchaseCompleter?.completeError(
                  UserCancelledPurchaseException('', StackTrace.current),
                );
              }
            } else if (eventName == 'checkout.warning') {
              final code = event['code'] as String;
              final detail = event['detail'] as String;
              final errors = event['errors'] as List<dynamic>;
              log('Checkout warning: $code - $detail - $errors');
            } else if (eventName == 'checkout.error') {
              final errorDetails = event['error']['detail'] as String;
              _purchaseCompleter?.completeError(
                UnknownSubscriptionException(errorDetails, StackTrace.current),
              );
            }
          } else if (event['type'] != null) {
            if (event['type'] as String == 'front-end_error') {
              log('Front end error: $event');
            }
          }
        }
      }),
    };

    // Convert to JS object
    final jsInitObject = js_util.jsify(initMap);

    // Now call the external method
    paddleInitialize(jsInitObject);

    if (userId != null) {
      unawaited(_fetchSubscriptionStatus(force: true));
    }
  }

  @override
  Future<void> login(String userId) async {
    await _fetchSubscriptionStatus(force: true);
  }

  @override
  Future<void> logout() async {}

  @override
  Future<void> purchaseSubscription(SubscriptionPackage package) async {
    _purchaseCompleter = Completer<void>();

    final userId = _firebaseAuth.currentUser!.uid;
    final email = _firebaseAuth.currentUser!.email;

    final checkoutOptions = {
      'items': [
        {
          'priceId': package.identifier,
        }
      ],
      'customData': {
        'userId': userId,
      },
      'customer': {
        'email': email,
      },
      if (package.discountId != null) 'discountId': package.discountId,
    };

    final jsOptions = js_util.jsify(checkoutOptions);

    openCheckout(jsOptions);

    // Wait for information from the eventCallback setup during initialisation
    await _purchaseCompleter!.future;

    return;
  }

  @override
  Future<void> refresh() async {
    await _fetchSubscriptionStatus();
  }

  @override
  Future<void> restorePurchase() {
    throw UnimplementedError();
  }
}

In the code above, I use a Completer to manage the asynchronous flow of the checkout process. When a user initiates a purchase, the completer is created and the app waits for specific events from Paddle (such as checkout.completed or errors). This basically allows inter-function asynchronous communication.

One critical point here is ensuring that the Paddle customer ID is set in the backend (in the user’s document) before the purchase is finalised. In the current implementation, I update the user document upon receiving events like customer.created or checkout.customer.updated. However, this approach isn’t foolproof (even though it does double-check that the write operation is successful) because if the user checks out too quickly, the update might not propagate in time. A more robust solution would be to implement a webhook which would listen to Paddle events and ensure that the Paddle customer ID is correctly saved in the backend.

SubscriptionStatusService

The SubscriptionStatusService class is used by both Revenue Cat and Paddle services. It caches the subscription status to reduce server calls and uses a Completer to manage concurrent requests.

import 'dart:async';
import 'dart:developer';

class SubscriptionStatusService {
  SubscriptionStatusService(
    this._firebaseFunctions,
    this._firebaseAuth,
    this._firestore,
  );

  final FirebaseFunctions _firebaseFunctions;
  final FirebaseAuth _firebaseAuth;
  final FirebaseFirestore _firestore;

  (DateTime cacheDateTime, UserSubscription? userSubscription)? _subscriptionCache;
  Completer<UserSubscription?>? _subscriptionRequest;

  Future<UserSubscription?> getSubscriptionStatus({bool force = false}) async {
    try {
      // If a request is already in progress, return its future
      if (_subscriptionRequest != null) {
        log('Subscription request in progress, awaiting result.');
        return _subscriptionRequest!.future;
      }

      if (!force && _subscriptionCache != null) {
        final (cacheDateTime, userSubscription) = _subscriptionCache!;
        if (DateTime.now().difference(cacheDateTime) < const Duration(seconds: 10)) {
          log('Returning subscription from cache - User subscription: $userSubscription');
          return userSubscription;
        } else {
          log('User subscription cache expired.');
          _subscriptionCache = null;
        }
      }

      if (force) {
        log('Subscription status forced. Fetching from server.');
        _subscriptionCache = null;
      }

      // Initialize the completer only once
      _subscriptionRequest ??= Completer<UserSubscription?>();

      try {
        // Fetch user data from Firestore
        final docRef = _firestore.collection("users").doc(_firebaseAuth.currentUser!.uid);
        final snapshot = await docRef.get();
        final docData = snapshot.data();
        final paddleCustomerId = docData?['paddleCustomerId'] as String?;
        log('paddleCustomerId: $paddleCustomerId');

        // If the user is on web or has a paddle customer ID, check their subscription status via cloud function
        if (kIsWeb || paddleCustomerId != null) {
          log('Checking subscription status via cloud function');
          final callable = _firebaseFunctions.httpsCallable('get_subscription_status');
          final result = await callable.call();
          final data = result.data as Map<String, dynamic>?;
          log('Data from cloud function: $data');

          if (data == null) {
            log('No subscription found from cloud function');
            final emptySubscription = const UserSubscription.empty();
            _subscriptionCache = (DateTime.now(), emptySubscription);
            _subscriptionRequest!.complete(emptySubscription);
            return emptySubscription;
          }

          final {
            'expirationDate': String expiration,
            'willRenew': bool willRenew,
            'isGifted': bool isGifted,
            'managementUrl': String? managementUrl,
          } = data;

          final expirationDate = DateTime.parse(expiration);
          final userSubscription = UserSubscription(
            isSubscribed: expirationDate.isAfter(DateTime.now()),
            expirationDate: expirationDate,
            subscriptionType: SubscriptionType.premium,
            willRenew: willRenew,
            isGifted: isGifted,
            managementUrl: managementUrl,
          );

          log('User subscription: $userSubscription');
          _subscriptionCache = (DateTime.now(), userSubscription);
          _subscriptionRequest!.complete(userSubscription);
          return userSubscription;
        } else {
          log('No paddle customer ID or is not web');
          _subscriptionCache = (DateTime.now(), null);
          _subscriptionRequest!.complete(null);
          return null;
        }
      } catch (e, st) {
        log('Error fetching subscription status from cloud function: $e, $st');
        _subscriptionRequest!.completeError(e, st);
        rethrow;
      } finally {
        // Only reset the completer when the request is fully resolved
        _subscriptionRequest = null;
      }
    } catch (e, st) {
      log('Unexpected error in getSubscriptionStatus: $e, $st');
      rethrow;
    }
  }
}

Conditional imports

To be able to use a web/JS library in an app that isn’t just for web, we need to conditionally import it. This means creating a stub for the web service. This stub does nothing (apart from appeasing the compiler).

I use Riverpod as my DI solution so you can see that the subscriptionServiceProvider returns the interface and then the rest of my app doesn’t know or care about the implementation details which makes managing a multi-platform app easier.

import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:stash_hub/services/service_provider_instances.dart';
import 'package:stash_hub/subscription/services/revenue_cat_subscription_service.dart';
import 'package:stash_hub/subscription/services/subscription_service.dart';
import 'package:stash_hub/subscription/services/subscription_status_service.dart';
import 'package:stash_hub/subscription/services/web_subscription_service_stub.dart'
    if (dart.library.js) 'package:stash_hub/subscription/services/web_subscription_service.dart';

final subscriptionStatusServiceProvider = Provider<SubscriptionStatusService>(
  (ref) => SubscriptionStatusService(
    ref.watch(firebaseCloudFunctionsProvider),
    ref.watch(firebaseAuthProvider),
    ref.watch(firebaseFirestoreProvider),
  ),
);

final subscriptionServiceProvider = Provider<SubscriptionService>(
  (ref) => kIsWeb
      ? WebSubscriptionService(
          ref.watch(firebaseCloudFunctionsProvider),
          ref.watch(firebaseAuthProvider),
          ref.watch(firebaseFirestoreProvider),
          ref.watch(subscriptionStatusServiceProvider),
        )
      : RevenueCatSubscriptionService(
          ref.watch(subscriptionStatusServiceProvider),
        ),
);

Webhooks

I thought I could get away without setting up a webhook but I have a referral program where users are rewarded when their referred friends subscribe to the app. This means that I need to react when a user subscribes and cannot rely on the client at all. The answer, is of course, webhooks. Fortunately, you can use Firebase Cloud Functions to set up a webhook.

Note to self: https_fn.on_request() is different from https_fn.on_call()! The former is used for handling HTTP requests, while the latter is used for handling function calls and parses the input differently.

I really liked how Paddle implements webhooks because you can specify which events to receive and trigger test messages. It even has the ability to trigger a sequence of events that are likely to happen when someone subscribes.

This function just listens for subscription events and then adds it to an existing Firestore collection where I already have a listener function that handles the rest of the referral logic.

@https_fn.on_request(region="europe-west1")
def paddle_subscription_webhook(req: https_fn.Request) -> https_fn.Response:
    try:
        is_prod = os.getenv("GCLOUD_PROJECT") == "stash-hub"
        webhook_secret = os.getenv("PADDLE_WEBHOOK_SECRET") if is_prod else os.getenv("PADDLE_SANDBOX_WEBHOOK_SECRET")
        integrity_check = Verifier().verify(req, Secret(webhook_secret))  # type: ignore
        if not integrity_check:
            return https_fn.Response(
                status=HTTPStatus.UNAUTHORIZED,
            )

        request_data = req.json

        if not request_data:
            return https_fn.Response(
                status=HTTPStatus.BAD_REQUEST,
            )

        event_type: str = request_data["event_type"]
        if event_type == "subscription.activated":
            data = request_data["data"]
            app_user_id = data["custom_data"].get("userId")
            if not app_user_id:
                return https_fn.Response(
                    status=HTTPStatus.BAD_REQUEST,
                )
            db.collection("REVENUECAT_EVENTS_COLLECTION").add(
                {
                    "type": "INITIAL_PURCHASE",
                    "app_user_id": app_user_id,
                    "paddle_raw": request_data,
                }
            )

        return https_fn.Response(
            status=HTTPStatus.OK,
        )
    except Exception as e:
        return https_fn.Response(
            status=HTTPStatus.INTERNAL_SERVER_ERROR,
        )

Conclusion

The desire to use a Merchant of Record led me down this route and I’m happy with the results (so far). I’ll be sure to update this guide should any changes occur.

I hope this guide will help others (and myself) to integrate Paddle (or something similar) into their Flutter web apps. If you have any comments or question, please feel free to reach out to me at contact@dougtodd.dev.