Using Firebase and RevenueCat to notify users that their free trials are ending
In our recent experiment, which pitted a free-trial timeline page against a long-form feature/benefit page (linked below), a core part of the test was notifying users that their trials would end two days before renewal.
Implementing this took some planning, but ultimately, it wasn't difficult with Firebase's scheduled and pub sub cloud functions and RevenueCat's new Firebase integration.
This integration creates two new collections in Firestore:
Customers
Events
Documents in the customers
collection are created and updated for each user. Think of this as equivalent to the customerInfo object returned by RevenueCat’s SDK.
Documents in the events
collection are essentially webhooks - purchase lifecycle events for your users - that you don’t have to catch and save yourself manually.
1 - Save future timestamp to user profile
The first step necessary was saving a timestamp on the user's profile when they started a new free trial. We offer a seven-day free trial on Clearful’s annual plans, so this timestamp was dated five days in the future, as we wanted to notify users two days before their trial was set to end. Shown below, the property in question is dateToNotifyFreeTrialEnding
.
Flutter
// Purchase method on our custom paywall
Future<void> purchasePackage(Package package) async {
try {
final info = await _billingService.purchasePackage(package);
final isPro = _billingService.isPro(purchaserInfo);
if (isPro) {
if (package.product.identifier == FREE_TRIAL_IDENTIFIER) {
_handleFreeTrialReminders();
}
}
} catch (error) {
// Handle error
}
}
Future<void> _handleFreeTrialReminders() async {
final data = <String, dynamic>{};
final date = DateTime.now();
// I keep track of when users see a consent dialog, which
// asks for permission to send them push notifications.
data['events.remindersPrompt'] = date;
// Show the push notification consent dialog
final result = await _showDialog(TrialNotificationDialog());
// Add the date to notify them if they consented
if (result == TrialNotificationDialogResult.enabled) {
final notifyDate = date.add(Duration(days: 5));
data['events.dateToNotifyFreeTrialEnding'] = notifyDate;
}
// Update the user's profile with this information
_userManager.update(data);
}
It's important to note that while I opted to save dateToNotifyFreeTrialEnding
in client code, this could also be achieved server-side by listening to the events
collection via the RevenueCat-Firebase integration. Here’s how this might be done:
NodeJS
exports.rcEventOnCreate = functions.firestore
.document("events/{eventID}")
.onCreate(async (snap, context) => {
try {
const event = snap.data();
// Check if the event is an initial purchase with trial
if (event.type === "INITIAL_PURCHASE" && event.period_type === "TRIAL") {
const userId = event.app_user_id;
// Set trial end date to 5 days from now
const notifyDate = new Date();
notifyDate.setDate(notifyDate.getDate() + 5);
// Update user document with trial end date
await admin.firestore().doc(`users/${userId}`).update({
"events.dateToNotifyFreeTrialEnding": notifyDate,
});
}
return null;
} catch (err) {
console.log(err);
Sentry.captureException(err);
return null;
}
});
Now that the date to notify the user has been saved on their profile, the next step is setting up two Firebase cloud functions to send the push notification.
2 - Get users on day five of their free trial
The first cloud function is scheduled to run every hour. Its job is to grab all users on day five of their free trial. Once the function has the list of users, it calls a second function that handles the notification logic for each user.
NodeJS
exports.notifyFreeTrialEnding = functions
.runWith({ failurePolicy: true })
.pubsub.schedule("0 * * * *")
.onRun(async (context) => {
const now = new Date();
now.setMinutes(0, 0, 0);
const start = now;
const end = new Date(now.getTime() + 60 * 60 * 1000); // add 1 hour
try {
const users = await db
.collection("users")
.where("events.dateToNotifyFreeTrialEnding", ">=", start)
.where("events.dateToNotifyFreeTrialEnding", "<", end)
.get()
.then((querySnapshot) => {
const users = [];
querySnapshot.forEach((doc) => {
const user = doc.data();
const userId = user.userId;
// Get push notification tokens (previously saved)
const tokens = user.notifications.push.tokens;
const data = {
userId,
tokens,
};
users.push(data);
});
return users;
});
if (users.length === 0) {
console.log(`notifyFreeTrialEnding: No users`);
return Promise.resolve(null);
}
const topic = "notify-free-trial-ending-user";
// Trigger the pubsub topic, which will send the notification for each user
const promises = users.map((user) =>
pubsubClient.topic(topic).publishMessage({ data: Buffer.from(JSON.stringify(user)) }),
);
return await Promise.all(promises);
} catch (err) {
console.log(err);
Sentry.captureException(err);
return Promise.reject(err);
}
});
3 - Notify users who are still on a free trial
The next function takes the userId
string and tokens
array and decides if the user is still on a free trial (i.e., they haven't already canceled). It does this by querying the customers
collection and fetching the customer document created (and continually updated) by RevenueCat.
It checks if the document contains the correct product identifier (much like the client function above) and ensures the user is still on trial by checking the period_type
and unsubscribe_detected_at
properties.
If everything checks out, it sends a push notification using Firebase's cloud messaging product.
NodeJS
exports.notifyFreeTrialEndingForUser = functions.pubsub
.topic("notify-free-trial-ending-user")
.onPublish(async (message, context) => {
const json = message.json;
const userId = json.userId;
const tokens = json.tokens;
if (helpers.isNullOrEmptyString(userId) || !tokens?.length) {
return Promise.resolve(null);
}
try {
const result = await db.collection("customers")
.where("aliases", "array-contains", userId)
.limit(1)
.get();
const docs = result.docs.map((snap) => snap.data());
if (docs.length === 0) {
return Promise.resolve(null);
}
const doc = docs[0];
const FREE_TRIAL_PRODUCT_ID = "FREE_TRIAL_IDENTIFIER";
// Check if the user has the free trial product
if (FREE_TRIAL_PRODUCT_ID in doc.subscriptions) {
const sub = doc.subscriptions[`${FREE_TRIAL_PRODUCT_ID}`];
// Check if the user is still on trial and has not unsubscribed
if (sub.period_type === "trial" && sub.unsubscribe_detected_at === null) {
console.log("Notifying free trial ending for user.");
return fcm.sendEachForMulticast({
notification: {
title: "2 days left on your Clearful trial",
body: "Your subscription will change from trial to Pro soon. Explore all the features now!",
},
data: {
click_action: "FLUTTER_NOTIFICATION_CLICK",
type: "trial_ending",
},
tokens: tokens,
});
}
}
return Promise.resolve(null);
} catch (err) {
console.log(err);
Sentry.captureException(err);
return Promise.reject(err);
}
});
4 - Enjoy higher conversions & user satisfaction!
When we started this test, we were worried that users wouldn't want to be notified and that the notifications themselves could harm conversions and retention.
We were pleasantly surprised when the opposite happened—opt-ins to push notifications were nearing 100% (for users who started a free trial), and the trial-to-paid conversion rate didn't drop.
Maybe this is why the free-trial timeline paywall and trial-ending notifications are often considered an Ethical Paywall™️ together, and even Apple recommends them.
Treat users right, and they’ll reward you.