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 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:
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 ofdart:js_interop
. Some of my code below usesjs_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.