Using Firebase and RevenueCat to notify users that their free trials are ending
3 min read

Using Firebase and RevenueCat to notify users that their free trials are ending

Building on the free-trial timeline pricing page test that performed so well, here's how we're notifying users, with Firebase and RevenueCat, that their free trials are ending.
Using Firebase and RevenueCat to notify users that their free trials are ending
Free-trial push notification reminder prompt

In our recent experiment that pitted a free-trial timeline page vs. a long-form feature/benefit page (linked below), a core part of the test was notifying users that their trials were going to end in two days.

Free-trial Timeline vs. Long-form Feature/Benefit Page
This app pricing page test performed surprisingly well but had a very interesting tradeoff.

This took some thought to plan out, but ultimately, it wasn't difficult to implement with Firebase's scheduled & pubsub cloud functions and RevenueCat's new Firebase integration.

The first step was saving a timestamp on the user's profile when they started a new free trial. This timestamp was dated five days in the future, as we wanted to notify them two days before their trial was set to end. Shown below, the property in question is dateToNotifyFreeTrialEnding.

1 - Save future timestamp to user profile

  Future<void> purchasePackage(Package package) async {
    try {
      final purchaserInfo = 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();
    
    data['events.remindersPrompt'] = date;
    
    final result = await _showDialog(TrialNotificationDialog());

    if (result == TrialNotificationDialogResult.enabled) {
      final notifyDate = date.add(Duration(days: 5));
      data['events.dateToNotifyFreeTrialEnding'] = notifyDate;
    }
    _userManager.update(data);
  }

It's important to note that, while I opted to save dateToNotifyFreeTrialEnding in client code, this could also be achieved by listening to the customers document via the RevenueCat-Firebase integration.

Next is setting up two firebase cloud functions.

2 - Get users on day five of their free trial

The first cloud function is scheduled to run every hour, and its job is to grab all users who are on day five of their free trial. Once the function has the list of users, it calls a pubsub function that handles the notification logic for each user.

exports.notifyFreeTrialEnding = functions.pubsub
  .schedule("0 * * * *")
  .onRun(async (context) => {
    const now = moment().minutes(0).seconds(0).milliseconds(0);
    const start = now.toDate();
    const end = now.clone().add(1, "hours").toDate();

    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;
            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";

      const promises = users.map((user) =>
        pubsubClient
          .topic(topic)
          .publishMessage({ data: Buffer.from(JSON.stringify(user)) })
      );

      return await Promise.all(promises);
    } catch (err) {
      Sentry.captureException(err);
      return Promise.reject(err);
    }
  });

3 - Notify users still on a free trial

The next function takes the userId string and tokens array and figures out 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 getting 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.

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;

    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) {
        // No customer document. Can't send a notification.
        return Promise.resolve(null);
      }

      const doc = docs[0];

      const FREE_TRIAL_PRODUCT_ID = "FREE_TRIAL_IDENTIFIER";

      if (FREE_TRIAL_PRODUCT_ID in doc.subscriptions) {
        const sub = doc.subscriptions[`${FREE_TRIAL_PRODUCT_ID}`];

        if (
          sub.period_type === "trial" &&
          sub.unsubscribe_detected_at === null
        ) {
          // They are still on trial. Send notification.
          
          const title = "2 days left on your Clearful trial";
          const body =
            "Your subscription will change from trial to Pro soon. Explore all the features now!";

          console.log("Notifying free trial ending for user.");
          
          return pushNotificationService.sendPushNotification(
            title,
            body,
            null,
            tokens
          );
        }
      }

      return Promise.resolve(null);
    } catch (err) {
      Sentry.captureException(err);
      return Promise.reject(err);
    }
  });

4 - Enjoy higher conversions & user satisfaction!

When we started this test, we were a bit 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 was nearing 100% (for users that started a free trial) and there wasn't a drop in trial to paid conversion rate.

It feels like users just want to be less anxious about starting a new subscription and treated with respect. Who would have thought? 😂