Skip to content
This repository has been archived by the owner on Mar 22, 2024. It is now read-only.

Change "current" Offering to "default" Offering when referencing a Project #542

Merged
merged 12 commits into from
Dec 5, 2023

Conversation

dpannasch
Copy link
Collaborator

Motivation / Description

Full background is here. TL;DR version is:

  1. When we release Targeting, the term current Offering will become even less appropriate than it already is, so we're taking the opportunity to change it default Offering when referencing "a Project's default Offering."
  2. In the context of getting Offerings, though, we'll continue to describe the Offering that should be served for a customer as their current Offering. So no SDK/API changes are needed, and in the docs and Dashboard anytime we are referring to what an individual customer will be served (e.g. in the Customer Profile) we will continue to call that their current Offering.

Changes introduced

I went through all of the places in our docs that need to be updated, but definitely need a QA to make sure:

  1. My changes make sense, and
  2. I didn't miss any

Linear ticket (if any)

https://linear.app/revenuecat/issue/PWL-426/update-docs-for-the-current-to-default-terminology-change

Additional comments

>
> The current Offering for a given customer may change based on the experiment they're enrolled in, any targeting rules they match, or the default Offering of your Project. Your Project's default Offering is the Offering that will be served as "current" when no other conditions apply for that customer.

To change the default Offering, navigate to the Offerings tab for your project in the RevenueCat dashboard and click **Make default** next to the Offering you'd like to enable.

![](https://files.readme.io/a6ff351-app.revenuecat.com_projects_85ff18c7_offerings_packages_pkge2ed0611690_attach_3.png "app.revenuecat.com_projects_85ff18c7_offerings_packages_pkge2ed0611690_attach (3).png")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This image and the corresponding instructions still need to be updated -- waiting on Dashboard changes to do that

@dpannasch dpannasch marked this pull request as ready for review November 30, 2023 19:48
@RCGitBot
Copy link
Contributor

RCGitBot commented Dec 4, 2023

Previews

temp/configuring-experiments-v1.md

See contents

Before setting up an experiment, make sure you've created the products and Offerings that you want to test and added any new products to the appropriate entitlements in your project (more on this below). You should also test the Offerings you've chosen on any platform your app supports.

Setting up a new experiment

[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/ba143ea-Screen_Shot_2022-11-30_at_3.39.08_PM.png",
"Screen Shot 2022-11-30 at 3.39.08 PM.png",
2510
],
"align": "center",
"caption": "Once enabled, you can access the Experiments tab under Product Setup."
}
]
}
[/block]

Select + New to create a new experiment.

[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/970b88e-Screen_Shot_2023-04-07_at_11.46.01_AM_1.png",
null,
null
],
"caption": "Creating a new experiment"
}
]
}
[/block]

Then, enter the following details:

  • Experiment name
  • Enrollment criteria (optional)
  • Control variant
    • The Offering that will be used for your Control group
  • Treatment variant
    • The Offering that will be used for your Treatment group (the variant in your experiment)

📘 Setting up an Offering for your treatment

If you've not setup multiple Offerings before, you'll be prompted to do so now, since you'll need at least 2 available Offerings to run an experiment.

The treatment Offering represents the hypothesis you're looking to test with your experiment (e.g. higher or lower priced products, products with trials, etc).

For App Store apps, we recommend setting up new products to test as a new Subscription Group so that customers who are offered those products through Experiments will see only that same set of products to select from their subscription settings.

Enrollment criteria

You may choose to setup custom enrollment criteria for your experiment to target a specific segment of your customers, using any combination of the following enrollment criteria:

Dimension Description
Apps Which of your RevenueCat apps the experiment will be made available to. Set to All apps by default.
Countries Which countries are eligible to have their new customers enrolled in the experiment. Set to All countries by default.
New customers to enroll The percent of new customers meeting the above criteria that will be enrolled in your experiment. Set to 100% by default.

Once done, select CREATE EXPERIMENT and view your new experiment.

Starting an experiment

When viewing a new experiment, you can start, edit, or delete the experiment.

  • Start: Starts the experiment. Customer enrollment and data collection begins immediately, but results will take up to 24 hours to begin populating.
  • Edit: Change the name, enrollment criteria, or Offerings in an experiment before it's been started. After it's been started, only the percent of new customers to enroll can be edited.
  • Delete: Deletes the experiment.

🚧 Sandbox

Test users will be placed into the experiment Offering variants, but sandbox purchases won't be applied to your experiment.

If you want to test your paywall to make sure it can handle displaying the Offerings in your experiment, you can use the Offering Override feature to choose a specific Offering to display to a user.

FAQs

[block:parameters]
{
"data": {
"h-0": "Question",
"h-1": "Answer",
"0-0": "Can I edit the Offerings in a started experiment?",
"0-1": "Editing an Offering for an active experiment would make the results unusable. Be sure to check before starting your experiment that your chosen Offerings render correctly in your app(s). If you need to make a change to your Offerings, stop the experiment and create a new one with the updated Offerings.",
"1-0": "Can I edit the enrollment criteria of a started experiment?",
"1-1": "Before an experiment has been started, all aspects of enrollment criteria can be edited. However, once an experiment has been started, only new customers to enroll can be edited; since editing the apps or countries that an experiment is exposed to would alter the nature of the test.",
"2-0": "Can I restart an experiment after it's been stopped?",
"2-1": "After you choose to stop an experiment, new customers will no longer be enrolled in it, and it cannot be restarted. If you want to continue a test, create a new experiment and choose the same Offerings as the stopped experiment. \n \n(NOTE: Results for stopped experiments will continue to refresh for 28 days after the experiment has ended)",
"3-0": "What happens to customers that were enrolled in an experiment after it's been stopped?",
"3-1": "New customers will no longer be enrolled in an experiment after it's been stopped, and customers who were already enrolled in the experiment will begin receiving the default Offering if they reach a paywall again. \n \nSince we continually refresh results for 28 days after an experiment has been ended, you may see conversions from these customers in your results, since they were enrolled as part of the test while it was running."
},
"cols": 2,
"rows": 4,
"align": [
"left",
"left"
]
}
[/block]

temp/creating-paywalls.md

See contents

[block:embed]
{
"html": "<iframe class="embedly-embed" src="//cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FPNiVCdExtkw%3Ffeature%3Doembed&display_name=YouTube&url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DPNiVCdExtkw&image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FPNiVCdExtkw%2Fhqdefault.jpg&key=7788cb384c9f4d5dbbdbeffd9fe4b92f&type=text%2Fhtml&schema=youtube" width="854" height="480" scrolling="no" title="YouTube embed" frameborder="0" allow="autoplay; fullscreen; encrypted-media; picture-in-picture;" allowfullscreen="true"></iframe>",
"url": "https://www.youtube.com/watch?v=PNiVCdExtkw",
"title": "How to use RevenueCat Paywalls",
"favicon": "https://www.google.com/favicon.ico",
"image": "https://i.ytimg.com/vi/PNiVCdExtkw/hqdefault.jpg",
"provider": "https://www.youtube.com/",
"href": "https://www.youtube.com/watch?v=PNiVCdExtkw",
"typeOfEmbed": "youtube"
}
[/block]

How to create a new Paywall

Select an Offering

First, click on Paywalls in the Products and pricing section of the Project you’re working on.

Products and pricing

Then, click + Add paywall next to the Offering that you want to create a Paywall for.

Add paywall

📘

If you’re looking to experiment with a new paywall, consider duplicating your default Offering and attaching your new paywall to the duplicated Offering.

Select a template

The first thing to do when creating a new Paywall is to select the template you’ll use as the starting point. Templates may support different package setups, content layouts, image sizes, and much more; so we recommend browsing each template to pick the one that’s best suited for what you’re looking to accomplish with your paywall.

For example, if you’re trying to draw contrast between a few different packages you’re offering, try the #2 - Sphynx template. Or, if you want to try your own version of the Blinkist Free Trial Paywall start with the #3 - Leopard template.

How to configure your Paywall

Once you’ve selected a template, you can configure any of its properties on the right side of the screen and see the change previewed immediately.

Packages

Packages represent the individual products you want to serve a customer on your Paywall. You don’t necessarily need to display every package that’s available in your Offering, and some templates may only support displaying one or a limited number of packages, so be sure to choose a template that reflects the options you want to offer your customers.

For templates that support multiple packages, you should select packages in the order that you’d like them to display. Then, you can separately choose which package should be preselected for your customers by default.

📘

To test the impact of that choice, you can duplicate your Offering, preselect a different package, and run an Experiment between the two Offerings to see how it influences customer behavior on your Paywall.

Strings

How you describe your product has a huge impact on how likely a customer is to subscribe to it. Every descriptive string on our Paywall templates is fully configurable so you have control over exactly how you pitch your product.

📘

Try using markdown formatting in any string property to add custom styling to your Paywall.

Variables

For some Paywall strings you may want to set values based on the package that’s being displayed instead of hardcoding a single value, such as when quoting a price, or describing the duration of an Introductory Offer.

To make this easier and ensure accuracy, we recommend using Variables for these values that are package-specific.

For example, to show a CTA like “Try 7 Days Free & Subscribe”, you should instead use the {{ sub_offer_duration }} variable, and enter “Try {{ sub_offer_duration }} Free & Subscribe” to ensure the string is accurate for any product you use, even if you make changes to the nature of the offer in the future.

We support the following variables:

Variable Description Example Value
product_name The name of the product from the store (e.g. product localized title from StoreKit) for a given package CatGPT
price The localized price of a given package $39.99
price_per_period The localized price of a given package with its period length if applicable $39.99/yr
total_price_and_per_month The localized price of a given package with its monthly equivalent price if it has one $39.99/yr ($3.33/mo)
sub_price_per_month The localized price of a given package converted to a monthly equivalent price $3.33
sub_duration The duration of the subscription; '1 month', '3 months', etc. 1 month
sub_period The length of each period of the standard offer on a given package Monthly
sub_offer_duration The period of the introductory offer on a given package 7 days
sub_offer_duration_2 The period of the second introductory offer on a given package (Google Play only) 7 days
sub_offer_price The localized price of the introductory offer of a given package $4.99
sub_offer_price_2 The localized price of the second introductory offer of a given package (Google Play only) $4.99

📘

Click the Show preview values checkbox to see your Paywall with example preview values instead of the raw variables.

Intro offer eligibility

RevenueCat Paywalls automatically check for Introductory Offer eligibility, and therefore for applicable fields like the Call to action and Offer details you can enter distinct strings based on the nature of the offer. For example, you may want to highlight the length of your free trial for a customer who is eligible for that Introductory Offer.

Uploading images

Use the Select a file button for the applicable image to upload your own to use for your Paywall. We’ll center and scale the image to fit, regardless of its aspect ratio, so we recommend using source images that are appropriate for the area of the template they cover. We support .jpg, jpeg, and .png files up to 5MB.

Colors

Use your own hex codes, select a custom color, or use our color picker to select background and text colors for each element that reflect your app’s branding.

📘

The color picker can be used outside of your browser window as well if you need to grab colors from assets in other applications.

Localization

RevenueCat Paywalls come with built-in support for localization. This will allow you to customize your paywall content for all the languages that your app supports.

Locales can be added to your paywall through the 'Localization' dropdown.

[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/c53fb56-Screenshot_2023-08-31_at_3.51.13_PM.png",
"",
""
],
"align": "center"
}
]
}
[/block]

Each paywall template may differ in the localized values that you will need to provide. The options that most templates have are:

  • Title
  • Subtitle
  • Package details
  • Package details for an introductory offer
  • Call to action
  • Call to action for an introductory offer

Since RevenueCatUI allows for dynamic text with Variables, all the output of variables will automatically localized. This includes period lengths like "Annual", "Monthly" and "Weekly" being localized to "Anual", "Mensual", and "Semanalmente". Price per period like "$6.99/mo" and "$53.99/yr" will also be localized to "$6.99/m." and "$53.99/año".

Other paywall components like "Restore purchases", "Terms of Service", and "Privacy Policy" will also automatically be localized.

Supported locales

We currently support all 39 locales that are supported on App Store Connect.

  • Arabic (Saudi Arabia) - ar
  • Catalan - ca
  • Chinese (Simplified) - zh
  • Chinese (Traditional) - zh
  • Croatian - hr
  • Czech - cs
  • Danish - da
  • Dutch (Netherlands) - nl
  • English (Australia) - en
  • English (Canada) - en
  • English (United Kingdom) - en
  • English (United States) - en
  • Finnish - fi
  • French (Canada) - fr
  • French (France) - fr
  • German (Germany) - de
  • Greek - el
  • Hebrew - he
  • Hindi - hi
  • Hungarian - hu
  • Indonesian - id
  • Italian - it
  • Japanese - ja
  • Korean - ko
  • Malay - ms
  • Norwegian - no
  • Polish - pl
  • Portuguese (Brazil) - pt
  • Portuguese (Portugal) - pt
  • Romanian - ro
  • Russian - ru
  • Slovak - sk
  • Spanish (Mexico) - es
  • Spanish (Spain) - es
  • Swedish - sv
  • Thai - th
  • Turkish - tr
  • Ukrainian - uk
  • Vietnamese - vi

temp/displaying-products.md

See contents

If you've configured Offerings in RevenueCat, you can control which products are shown to users without requiring an app update. Building paywalls that are dynamic and can react to different product configurations gives you maximum flexibility to make remote updates.

📘

Before products and offerings can be fetched from RevenueCat, be sure to initialize the Purchases SDK by following our Quickstart guide.

Fetching Offerings

Offerings are fetched through the SDK based on their configuration in the RevenueCat dashboard.

The getOfferings method will fetch the Offerings from RevenueCat. These are pre-fetched in most cases on app launch, so the completion block to get offerings won't need to make a network request in most cases.

Purchases.shared.getOfferings { (offerings, error) in
    if let packages = offerings?.current?.availablePackages {
        self.display(packages)
    }
}
[[RCPurchases sharedPurchases] getOfferingsWithCompletion:^(RCOfferings *offerings, NSError *error) {
  if (offerings.current && offerings.current.availablePackages.count != 0) {
    // Display packages for sale
  } else if (error) {
    // optional error handling
  }
}];
Purchases.sharedInstance.getOfferingsWith({ error ->
  // An error occurred
}) { offerings ->
  offerings.current?.availablePackages?.takeUnless { it.isNullOrEmpty() }?.let {
    // Display packages for sale
  }
}
Purchases.getSharedInstance().getOfferings(new ReceiveOfferingsCallback() {
  @Override
  public void onReceived(@NonNull Offerings offerings) {
    if (offerings.getCurrent() != null) {
      List<Package> availablePackages = offerings.getCurrent().getAvailablePackages();
      // Display packages for sale
    }
  }
  
  @Override
  public void onError(@NonNull PurchasesError error) {
    // An error occurred
  }
});
try {
  Offerings offerings = await Purchases.getOfferings();
  if (offerings.current != null && offerings.current.availablePackages.isNotEmpty) {
    // Display packages for sale
  }
} on PlatformException catch (e) {
	// optional error handling
}
try {
  const offerings = await Purchases.getOfferings();
  if (offerings.current !== null && offerings.current.availablePackages.length !== 0) {
    // Display packages for sale
  }
} catch (e) {
 
}
func displayUpsellScreen() {
  Purchases.getOfferings(
      offerings => {
        if (offerings.current !== null && offerings.current.availablePackages.length !== 0) {  
			    // Display packages for sale
        }
      },
      error => {

      }
  );
}
const displayUpsellScreen = async () => {
  try {
    const offerings = await Purchases.getOfferings();
    if (offerings.current !== null && offerings.current.availablePackages.length !== 0) {  
      // Display packages for sale
    }
  } catch (error) {
    // Handle error
  }
}
var purchases = GetComponent<Purchases>();
purchases.GetOfferings((offerings, error) =>
{
  if (offerings.Current != null && offerings.Current.AvailablePackages.Count != 0){
    // Display packages for sale
  }
});

📘 Offerings, products or available packages empty

If your offerings, products, or available packages are empty, it's due to some configuration issue in App Store Connect or the Play Console.

The most common reasons for this in App Store Connect are an out-of-date 'Paid Applications Agreement' or products not at least in the 'Ready To Submit' state. For Google Play, this usually occurs when the app is not published on a closed track and a valid test user added.

You can find more info about trouble shooting this issue in our Help Center.

You must choose one Offering that is the "Default Offering" - which can easily be accessed via the current property of the returned offerings for a given customer.

📘 What's the difference between a current Offering and a default Offering?

The current Offering for a given customer may change based on the experiment they're enrolled in, any targeting rules they match, or the default Offering of your Project. Your Project's default Offering is the Offering that will be served as "current" when no other conditions apply for that customer.

To change the default Offering, navigate to the Offerings tab for your project in the RevenueCat dashboard and click Make default next to the Offering you'd like to enable.

Offerings can be updated at any time, and the changes will go into effect for all users right away.

Custom Offering identifiers

It's also possible to access other Offerings besides the Default Offering directly by its identifier.

Purchases.shared.getOfferings { (offerings, error) in
    if let packages = offerings?.offering(identifier: "experiment_group")?.availablePackages {
        self.display(packages)
    }
}
[[RCPurchases sharedPurchases] offeringsWithCompletionBlock:^(RCOfferings *offerings, NSError *error) {
	NSArray<RCPackage *> *availablePackages = [offerings offeringWithIdentifier:"experiment_group"].availablePackages;
  if (availablePackages) {
    // Display packages for sale
  }
}];
Purchases.sharedInstance.getOfferingsWith({ error ->
  // An error occurred
}) { offerings ->
  offerings["experiment_group"]?.availablePackages?.takeUnless { it.isNullOrEmpty() }?.let {
    // Display packages for sale
  }
}
Purchases.getSharedInstance().getOfferings(new ReceiveOfferingsCallback() {
  @Override
  public void onReceived(@NonNull Offerings offerings) {
    if (offerings.get("experiment_group") != null) {
      List<Package> availablePackages = offerings.get("experiment_group").getAvailablePackages();
      // Display packages for sale
    }
  }
  
  @Override
  public void onError(@NonNull PurchasesError error) {
    // An error occurred
  }
});
try {
  Offerings offerings = await Purchases.getOfferings();
  if (offerings.getOffering("experiment_group").availablePackages.isNotEmpty) {
    // Display packages for sale
  }
} on PlatformException catch (e) {
	// optional error handling
}
try {
  const offerings = await Purchases.getOfferings();
  if (offerings.all["experiment_group"].availablePackages.length !== 0) {
    // Display packages for sale
  }
} catch (e) {
 
}
Purchases.getOfferings(
      offerings => {
        if (offerings.all["experiment_group"].availablePackages.length !== 0) {
			    // Display packages for sale
        }
      },
      error => {

      }
  );
try {
  const offerings = await Purchases.getOfferings();
  if (offerings.all["experiment_group"].availablePackages.length !== 0) {  
    // Display packages for sale
  }
} catch (error) {
  // Handle error
}
var purchases = GetComponent<Purchases>();
purchases.GetOfferings((offerings, error) =>
{
  if (offerings.All.ContainsKey("experiment_group") && offerings.All["experiment_group"].AvailablePackages.Count != 0) {
  	// Display packages for sale
  }
});

Displaying Packages

Packages help abstract platform-specific products by grouping equivalent products across iOS, Android, and web. A package is made up of three parts: identifier, type, and underlying store product.

[block:parameters]
{
"data": {
"h-0": "Name",
"h-1": "Description",
"0-0": "Identifier",
"0-1": "The package identifier (e.g. com.revenuecat.app.monthly)",
"1-0": "Type",
"1-1": "The type of the package: \n- UNKNOWN \n- CUSTOM \n- LIFETIME \n- ANNUAL \n- SIX_MONTH \n- THREE_MONTH \n- TWO_MONTH \n- MONTHLY \n- WEEKLY",
"2-0": "Product",
"2-1": "The underlying product that is mapped to this package which includes details about the price and duration."
},
"cols": 2,
"rows": 3,
"align": [
"left",
"left"
]
}
[/block]

Packages can be access in a few different ways:

  1. via the .availablePackages property on an Offering.
  2. via the duration convenience property on an Offering
  3. via the package identifier directly
let packages = offerings.offering(identifier: "experiment_group")?.availablePackages
// --
let monthlyPackage = offerings.offering(identifier: "experiment_group")?.monthly
// --
let packageById = offerings.offering(identifier: "experiment_group")?.package(identifier: "<package_id>")
[offerings offeringWithIdentifier:"experiment_group"].availablePackages
// --
[offerings offeringWithIdentifier:"experiment_group"].monthly
// --
[[offerings offeringWithIdentifier:"experiment_group"] packageWithIdentifier:@"<package_id>"]
offerings["experiment_group"]?.availablePackages
// --
offerings["experiment_group"]?.monthly
// --
offerings["experiment_group"]?.getPackage("<package_id>")
offerings.getOffering("experiment_group").availablePackages
// --
offerings.getOffering("experiment_group").monthly
// --
offerings.getOffering("experiment_group").getPackage("<package_id>")
offerings.all["experiment_group"].availablePackages
// --
offerings.all["experiment_group"].monthly
// --
offerings.all["experiment_group"].availablePackages.find(package => package === "<package_id>")
offerings.all["experiment_group"].availablePackages
// --
offerings.all("experiment_group").monthly
// --
offerings.all("experiment_group").package("<package_id>")
offerings.All["experiment_group"].AvailablePackages
// --
offerings.All["experiment_group"].Monthly
// --
// Manually filter AvailablePackages by the custom package identifier

Getting the Product from the Package

Each Package includes an underlying product that includes more information about the price, duration, and other metadata. You can access the product via the storeProduct property:

Purchases.shared.getOfferings { (offerings, error) in
    // Accessing the monthly product
    if let product = offerings?.current?.monthly?.storeProduct {
        // Display the product information (like price and introductory period)
        self.display(product)
    }
}
// Accessing the monthly product

[[RCPurchases sharedPurchases] offeringsWithCompletionBlock:^(RCOfferings *offerings, NSError *error) {
  if (offerings.current && offerings.current.monthly) {
    SKProduct *product = offerings.current.monthly.storeProduct;
    // Get the price and introductory period from the StoreProduct
  } else if (error) {
    // optional error handling
  }
}];
// Accessing the monthly product

Purchases.sharedInstance.getOfferingsWith({ error ->
  // An error occurred
}) { offerings ->
  val product = offerings.current?.monthly?.product?.also {
    // Get the price and introductory period from the SkuDetails
  }
}
// Accessing the monthly product

Purchases.getSharedInstance().getOfferings(new ReceiveOfferingsCallback() {
  @Override
  public void onReceived(@NonNull Offerings offerings) {
    if (offerings.getCurrent() != null && offerings.getCurrent().getMonthly() != null) {
      StoreProduct product = offerings.getCurrent().getMonthly().getProduct();
      // Get the price and introductory period from the StoreProduct
    }
  }
  
  @Override
  public void onError(@NonNull PurchasesError error) {
    // An error occurred
  }
});
// Accessing the monthly product// Displaying the monthly product

try {
  Offerings offerings = await Purchases.getOfferings();
  if (offerings.current != null && offerings.current.monthly != null) {
    Product product = offerings.current.monthly.product;
    // Get the price and introductory period from the Product
  }
} on PlatformException catch (e) {
	// optional error handling
}
// Accessing the monthly product// Displaying the monthly product

try {
  const offerings = await Purchases.getOfferings();
  if (offerings.current && offerings.current.monthly) {
    const product = offerings.current.monthly;
    // Get the price and introductory period from the PurchasesProduct
  }
} catch (e) {
 
}
// Accessing the monthly product

func displayUpsellScreen() {
  Purchases.getOfferings(
      offerings => {
        if (offerings.current && offerings.current.monthly) {  
          const product = offerings.current.monthly;
			    // Get the price and introductory period from the PurchasesProduct
        }
      },
      error => {

      }
  );
}
// Accessing the monthly product

const displayUpsellScreen = async () => {
  try {
    const offerings = await Purchases.getOfferings();
    if (offerings.current && offerings.current.monthly) {
      const product = offerings.current.monthly;  
      // Get the price and introductory period from the PurchasesProduct
    }
  } catch (error) {
    // Handle error
  }
}
// Accessing the monthly product

var purchases = GetComponent<Purchases>();
purchases.GetOfferings((offerings, error) =>
{
  if (offerings.Current != null && offerings.Current.Monthly != null){
    var product = offerings.Current.Monthly.Product;
    // Get the price and introductory period from the Product
  }
});

Choosing which Offering to display

In practice, you may not want to display the default current Offering to every user and instead have a specific cohort that see a different Offering.

For example, displaying a higher priced Offering to users that came from paid acquisition to help recover ad costs, or a specific Offering designed to show iOS Subscription Offers when a user has cancelled their subscription.

This can be accomplished with custom Offering identifiers for each of these "cohorts".

Purchases.shared.getOfferings { (offerings, error) in
    var packages: [Package]?

    if user.isPaidDownload {
        packages = offerings?.offering(identifier: "paid_download_offer")?.availablePackages
    } else if user.signedUpOver30DaysAgo {
        packages = offerings?.offering(identifier: "long_term_offer")?.availablePackages
    } else if user.recentlyChurned {
        packages = offerings?.offering(identifier: "ios_subscription_offer")?.availablePackages
    }

    // Present your paywall
    self.display(packages)
}
[[RCPurchases sharedPurchases] offeringsWithCompletionBlock:^(RCOfferings *offerings, NSError *error) {
  NSArray<RCPackage *> *packages;
  
  if (user.isPaidDownload) {
    packages = [offerings offeringWithIdentifier:"paid_download_offer"].availablePackages;
  } else if (user.signedUpOver30DaysAgo) {
    packages = [offerings offeringWithIdentifier:"long_term_offer"].availablePackages;
  } else if (user.recentlyChurned) {
    packages = [offerings offeringWithIdentifier:"ios_subscription_offer"].availablePackages;
  }
  
  [self presentPaywallWithPackages:packages];
}];
Purchases.sharedInstance.getOfferingsWith({ error ->
  // An error occurred
}) { offerings ->
  val packages: Package? = when {
    user.isPaidDownload -> offerings["paid_download_offer"]?.availablePackages
    user.signedUpOver30DaysAgo -> offerings["long_term_offer"]?.availablePackages
    user.recentlyChurned -> offerings["ios_subscription_offer"].availablePackages
    else -> null
  }
	presentPaywall(packages)
}
Purchases.getSharedInstance().getOfferings(new ReceiveOfferingsCallback() {
  @Override
  public void onReceived(@NonNull Offerings offerings) {
    List<Package> packages = null;
    if (user.isPaidDownload) {
      if (offerings.get("paid_download_offer") != null) {
        packages = offerings.get("paid_download_offer").getAvailablePackages();
      }
    } else if (user.signedUpOver30DaysAgo) {
      if (offerings.get("long_term_offer") != null) {
        packages = offerings.get("long_term_offer").getAvailablePackages();
      }
    }
    presentPaywall(packages);
  }
  
  @Override
  public void onError(@NonNull PurchasesError error) {
    // An error occurred
  }
});
try {
  Offerings offerings = await Purchases.getOfferings();
  var packages;
  if (user.isPaidDownload) {
    packages = offerings?.getOffering("paid_download_offer")?.availablePackages;
  } else if (user.signedUpOver30DaysAgo) {
    packages = offerings?.getOffering("long_term_offer")?.availablePackages;
  } else if (user.recentlyChurned) {
    packages = offerings?.getOffering("ios_subscription_offer")?.availablePackages;
  }
  presentPaywall(packages);
} on PlatformException catch (e) {
	// optional error handling
}
try {
  const offerings = await Purchases.getOfferings();
  let packages;
  if (user.isPaidDownload) {
    packages = offerings.all["paid_download_offer"].availablePackages;
  } else if (user.signedUpOver30DaysAgo) {
    packages = offerings.all["long_term_offer"].availablePackages;
  } else if (user.recentlyChurned) {
    packages = offerings.all["ios_subscription_offer"].availablePackages;
  }
  presentPaywall(packages);
} catch (e) {
 
}
Purchases.getOfferings(
      offerings => {
        let packages;
        if (user.isPaidDownload) {
          packages = offerings.all["paid_download_offer"].availablePackages;
        } else if (user.signedUpOver30DaysAgo) {
          packages = offerings.all["long_term_offer"].availablePackages;
        } else if (user.recentlyChurned) {
          packages = offerings.all["ios_subscription_offer"].availablePackages;
        }
        presentPaywall(packages);
      },
      error => {

      }
  );
Purchases.getOfferings(
      offerings => {
        let packages;
        if (user.isPaidDownload) {
          packages = offerings.all["paid_download_offer"].availablePackages;
        } else if (user.signedUpOver30DaysAgo) {
          packages = offerings.all["long_term_offer"].availablePackages;
        } else if (user.recentlyChurned) {
          packages = offerings.all["ios_subscription_offer"].availablePackages;
        }
        presentPaywall(packages);
      },
      error => {

      }
  );
var purchases = GetComponent<Purchases>();
purchases.GetOfferings((offerings, error) =>
{
  List<Purchases.Package> packages;
  if (user.isPaidDownload) {
    packages = offerings.All["paid_download_offer"].AvailablePackages;
  } else if (user.signedUpOver30DaysAgo) {
    packages = offerings.All["long_term_offer"].AvailablePackages;
  } else if (user.recentlyChurned) {
    packages = offerings.All["ios_subscription_offer"].AvailablePackages;
  }
  presentPaywall(packages);
});

📘

As of now, cohort logic needs to be managed outside of RevenueCat.

Best Practices

Do Don't
✅ Make paywalls dynamic by minimizing or eliminating any hardcoded strings ❌ Make static paywalls hardcoded with specific product IDs
✅ Use default package types ❌ Use custom package identifiers in place of a default option
✅ Allow for any number of product choices ❌ Support only a fixed number of products
✅ Support for different free trial durations, or no free trial ❌ Hardcode free trial text

Next Steps

  • Now that you've shown the correct products to users, time to make a purchase
  • Check out our sample apps for examples of how to display products.

temp/experiments-overview-v1.md

See contents

Experiments allow you to answer questions about your users' behaviors and app's business by A/B testing two Offerings in your app and analyzing the full subscription lifecycle to understand which variant is producing more value for your business.

While price testing is one of the most common forms of A/B testing in mobile apps, Experiments are based on RevenueCat Offerings, allowing you A/B test more than just prices, including: trial length, subscription length, different groupings of products, etc.

Plus, by attaching metadata to your Offerings and programming your paywall to be responsive to it, you can remotely test any aspect of your paywall. Learn more here.

📘

Experiments is available to Pro & Enterprise customers. Learn more about pricing here.

How does it work?

After configuring the two Offerings you want and adding them to an Experiment, RevenueCat will randomly assign users to a cohort where they will only see one of the two Offerings. Everything is done server-side, so no changes to your app are required if you're already displaying the current Offering for a given customer in your app!

🚧

Programmatically displaying the current Offering in your app when you fetch Offerings is required to ensure customers are evenly split between variants.

If you need help making your paywall more dynamic, see Displaying Products. The Swift sample app has an example of a dynamic paywall that is Experiments-ready. Dynamic paywall examples in other languages can be found within our other sample apps as well.

📘

To learn more about creating a new Offering to test, and some tips to keep in mind when creating new Products on the stores, check out our guide here.

As soon as a customer is enrolled in an experiment, they'll be included in the "Customers" count on the Experiment Results page, and you'll see any trial starts, paid conversions, status changes, etc. represented in the corresponding metrics. (Learn more here)

📘

We recommend identifying customers before they reach your paywall to ensure that one unique person accessing your app from two different devices is not treated as two unique anonymous customers.

Implementation steps

Experiments requires you to use Offerings and have a dynamic paywall in your app that displays the current Offering for a given customer. While Experiments will work with iOS and Android SDKs 3.0.0+, it is recommended to use these versions:

SDK Version
iOS 3.5.0+
Android 3.2.0+
Flutter 1.2.0+
React Native 3.3.0+
Cordova 1.2.0+
Unity 2.0.0+

If you meet these requirements, you can start using Experiments without any app changes! If not, take a look at Displaying Products. The Swift sample app has an example of a dynamic paywall that is Experiments-ready.

Implementation Overview

  1. Create two Offerings that you want to test (make sure your app displays the current Offering.) You can skip this step if you already have the Offerings you want to test.
  2. Create an Experiment and choose the two Offerings to test.
  3. Run your experiment and monitor the results. There is no time limit on experiments, so stop it when you feel confident choosing an outcome. (Learn more about interpreting your results here)
  4. Once you’re satisfied with the results you can set the winning Offering, if any, as default manually.
  5. Then, you're ready to run a new experiment.

Tips for Using Experiments

Decide how long you want to run your experiments

There’s no time limit on tests. Consider the timescales that matter for you. For example, if comparing monthly vs yearly, yearly might outperform in the short term because of the high short term revenue, but monthly might outperform in the long term.

Keep in mind that if the difference in performance between your variants is very small, then the likelihood that you're seeing statistically significant data is lower as well. "No result" from an experiment is still a result: it means your change was likely not impactful enough to help or hurt your performance either way.

📘

You can’t restart a test once it's been stopped.

** Test only one variable at a time**

It's tempting to try to test multiple variables at once, such as free trial length and price; resist that temptation! The results are often clearer when only one variable is tested. You can run more tests for other variables as you further optimize your LTV.

** Bigger changes will validate faster**

Small differences ($3 monthly vs $2 monthly) will often show ambiguous results and may take a long time to show clear results. Try bolder changes like $3 monthly vs $10 monthly to start to triangulate your optimal price.

** You can run only one test at a time**

If you want to run another test, you must stop the one currently running. You can, however, create as many tests as you need.

** Running a test with a control**

Sometimes you want to compare a different Offering to the one that is already the default. If so, you can set one of the variants to the Offering that is currently used in your app.

** Run follow-up tests after completing one test**

After you run a test and find that one Offering won over the other, try running another test comparing the winning Offering against another similar Offering. This way, you can continually optimize for lifetime value (LTV). For example, if you were running a price test between a $5 product and a $7 product and the $7 Offering won, try running another test between a $8 product and the $7 winner to find the optimal price for the product that results in the highest LTV.

temp/experiments-results-v1.md

See contents

Within 24 hours of your experiment's launch you'll start seeing data on the Results page. RevenueCat offers experiment results through each step of the subscription journey to give you a comprehensive view of the impact of your test. You can dig into these results in a few different ways, which we'll cover below.

Results chart

The Results chart should be your primary source for understanding how a specific metric has performed for each variant over the lifetime of your experiment.

By default you'll see your *Realized LTV per customer for all platforms plotted daily for the lifetime of your experiment, but you can select any other experiment metric to visualize, or narrow down to a specific platform.
[block:callout]
{
"type": "info",
"title": "Why Realized LTV per customer?",
"body": "Lifetime value (LTV) is the standard success measure you should be using for pricing experiments because it captures the full revenue impact on your business. Realized LTV per customer measures the revenue you've accrued so far divided by the total customers in each variant so you understand which variant is on track to produce higher value for your business.\n\nKeep in mind that your LTV over a longer time horizon might be impacted by the renewal behavior of your customers, the mix of product durations they're on, etc."
}
[/block]
You can also click Export chart CSV to receive an export of all metrics by day for deeper analysis.
[block:callout]
{
"type": "info",
"title": "Data takes 24 hours to appear",
"body": "The results refresher runs once every 24 hours.\n \nIf you're not seeing any data or are seeing unexpected results, try:\n- Ensuring each product that is a part of the experiment has been purchased at least once\n- Waiting another 24 hours until the model can process more data\n\nWhen you stop an experiment, the results will continue to be updated for a full year to capture any additional subscription events, and allow you to see how your Realized LTV matures for each variant over time."
}
[/block]

Customer journey tables

The customer journey tables can be used to dig into and compare your results across variants.

The customer journey for a subscription product can be complex: a "conversion" may only be the start of a trial, a single payment is only a portion of the total revenue that subscription may eventually generate, and other events like refunds and cancellations are critical to understanding how a cohort is likely to monetize over time.

To help parse your results, we've broken up experiment results into three tables:

  1. Initial conversion: For understanding how these key early conversion rates have been influenced by your test. These metrics are frequently the strongest predictors of LTV changes in an experiment.
  2. Paid customers: For understanding how your initial conversion trends are translating into new paying customers.
  3. Revenue: For understanding how those two sets of changes interact with each other to yield overall impact to your business.

    In addition to the results per variant that are illustrated above, you can also analyze most metrics by product as well. Click on the caret next to "All" within metrics that offer it to see the metric broken down by the individual products in your experiment. This is especially helpful when trying to understand what's driving changes in performance, and how it might impact LTV. (A more prominent yearly subscription, for example, may decrease initial conversion rate relative to a more prominent monthly option; but those fewer conversions may produce more Realized LTV per paying customer)
    [block:image]
    {
    "images": [
    {
    "image": [
    "https://files.readme.io/2326dd9-Untitled_1.png",
    "Untitled (1).png",
    2419,
    1128,
    "#000000"
    ],
    "caption": ""
    }
    ]
    }
    [/block]
    The results from your experiment can also be exported in this table format using the Export data CSV button. This will included aggregate results per variant, and per product results, for flexible analysis.

🚧 Automatic emails for poor performing tests

If the Realized LTV of your Treatment is performing meaningfully worse than your Control, we'll automatically email you to let you know about it so that you can run your test with confidence.

Metric definitions

Initial conversion metric definitions

[block:parameters]
{
"data": {
"h-0": "Metric",
"h-1": "Definition",
"0-0": "Customers",
"0-1": "All new customers who've been included in each variant of your experiment.",
"1-0": "Initial conversions",
"1-1": "A purchase of any product offered to a customer in your experiment. This includes products with free trials and non-subscription products as well.",
"2-0": "Initial conversion rate",
"2-1": "The percent of customers who purchased any product.",
"3-0": "Trials started",
"3-1": "The number of trials started.",
"4-0": "Trials completed",
"5-0": "Trials converted",
"6-0": "Trial conversion rate",
"4-1": "The number of trials completed. A trial may be completed due to its expiration or its conversion to paid.",
"5-1": "The number of trials that have converted to a paying subscription. Keep in mind that this metric will lag behind your trials started due to the length of your trial. For example, if you're offering a 7-day trial, for the first 6 days of your experiment you will see trials started but none converted yet.",
"6-1": "The percent of your completed trials that converted to paying subscriptions."
},
"cols": 2,
"rows": 7
}
[/block]

Paid customers metric definitions

[block:parameters]
{
"data": {
"h-0": "Metric",
"h-1": "Definition",
"0-0": "Paid customers",
"1-0": "Conversion to paying",
"2-0": "Active subscribers",
"3-0": "Churned subscribers",
"4-0": "Refunded customers",
"0-1": "The number of customers who made at least 1 payment. This includes payments for non-subscription products, but does NOT include free trials.\n\nCustomers who later received a refund will be counted in this metric, but you can use "Refunded customers" to subtract them out.",
"1-1": "The percent of customers who made at least 1 payment.",
"2-1": "The number of customers with an active subscription as of the latest results update.",
"3-1": "The number of customers with a previously active subscription that has since churned as of the latest results update. A subscriber is considered churned once their subscription has expired (which may be at the end of their grace period if one was offered).",
"4-1": "The number of customers who've received at least 1 refund."
},
"cols": 2,
"rows": 5
}
[/block]

Revenue metric definitions

[block:parameters]
{
"data": {
"h-0": "Metric",
"h-1": "Definition",
"0-0": "Realized LTV (revenue)",
"1-0": "Realized LTV per customer",
"2-0": "Realized LTV per paying customer",
"3-0": "Total MRR",
"4-0": "MRR per customer",
"5-0": "MRR per paying customer",
"0-1": "The total revenue you've received (realized) from each experiment variant.",
"1-1": "The total revenue you've received (realized) from each experiment variant, divided by the number of customers in each variant.\n\nThis should frequently be your primary success metric for determining which variant performed best.",
"2-1": "The total revenue you've received (realized) from each experiment variant, divided by the number of paying customers in each variant.\n\nCompare this with "Conversion to paying" to understand if your differences in Realized LTV are coming the payment conversion funnel, or from the revenue generated from paying customers.",
"3-1": "The total monthly recurring revenue your current active subscriptions in each variant would generate on a normalized monthly basis.\n\nLearn more about MRR here.",
"4-1": "The total monthly recurring revenue your current active subscriptions in each variant would generate on a normalized monthly basis, divided by the number of customers in each variant.",
"5-1": "The total monthly recurring revenue your current active subscriptions in each variant would generate on a normalized monthly basis, divided by the number of paying customers in each variant."
},
"cols": 2,
"rows": 6
}
[/block]

[block:callout]
{
"type": "info",
"title": "Only new users are included in the results",
"body": "To keep your A and B cohorts on equal footing, only new users are added to experiments. Here's an example to illustrate what can happen if existing users are added to an experiment: an existing user who is placed in a cohort might make a purchase they wouldn't otherwise make because the variant they were shown had a lower price than the default offering they previously saw. This might mean that the user made a purchase out of fear that they were missing out on a sale and wanted to lock in the price in anticipation of it going back up."
}
[/block]

FAQs

[block:parameters]
{
"data": {
"h-0": "Question",
"h-1": "Answer",
"0-0": "What is included in the "Other" category in the product-level breakdown of my results?",
"0-1": "If the customers enrolled in your experiment purchased any products that were not included in either the Control or Treatment Offering, then they will be listed in the "Other" category when reviewing the product-level breakdown of a metric. \n \nThis is to ensure that all conversions and revenue generated by these customers can be included when measuring the total revenue impact of one variant vs. another, even if that revenue was generated from other areas of the product experience (like a special offer triggered in your app).",
"1-0": "Why do the results for one variant contain purchases of products not included in that variant's Offering?",
"1-1": "There are many potential reasons for this, but the two most common occur when (1) there are areas of your app that serve products outside of the Current Offering returned by RevenueCat for a given customer, or (2) the offered Subscription Group on the App Store contains additional products outside of that variant's Offering. \n \nFor the first case, please check and confirm that all places where you serve Products in your app are relying on the Current Offering from RevenueCat to determiner what to display. \n \nFor the second case, we recommend creating new Subscription Groups on the App Store for each Offering so that a customer who purchases from that Offering will only have that same set of options to select from one when considering changing or canceling their subscription from Subscription Settings on iOS.",
"2-0": "When I end an Experiment, what Offering will be served to the customers who were enrolled in that Experiment?",
"2-1": "When an Experiment is ended, all customers previously enrolled in it will be served the Default Offering the next time they reach a paywall in your app.",
"3-0": "How can I review the individual transactions that have occurred in my experiment?",
"3-1": "Our Scheduled Data Exports include the experiment enrollment of each subscriber in the reported transactions, and by subscribing to them you can receive daily exports of all of your transactions to analyze the experiment results further."
},
"cols": 2,
"rows": 4,
"align": [
"left",
"left"
]
}
[/block]

temp/offering-metadata.md

See contents

Metadata allows you to attach a custom JSON object to your Offering that can be used to control how to display your products inside your app, determine the Offering to show based on provided attributes, and much more. The metadata you configure in an Offering is available from the RevenueCat SDK. For example, you could use it to remotely configure strings on your paywall, or even URLs of images shown on the paywall.

Offering metadata is supported in the following SDK versions:

RevenueCat SDK Version required for Offering Metadata
purchases-ios 4.20.0 and up
purchases-android 6.3.0 and up
react-native-purchases 6.0.0 and up
purchases-flutter 5.0.0 and up
cordova-plugin-purchases 4.0.0 and up
purchases-unity 5.0.0 and up

Benefits of using Offering metadata

Using Offering metadata has several advantages:

  • You can remotely configure aspects of your paywall and upsell messaging and make changes without deploying any code, creating a new app build, or going through app review.
  • You can use offering metadata together with Offering Override to display messaging for special offers and discounts. For example, you could create a key discount_message that, if present, shows a special message about the applied discount on the paywall, and set that on a discounted Offering that you apply as an override to customers who are eligible for the specific discount.
  • You can use Experiments in conjunction with Offering metadata to not only A/B test different products and prices, but also to test changes to the paywall. To do that, you would create a second Offering with the same products as your default offering, but have different values for the metadata keys in the second Offering.

[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/febdc0b-Screen_Shot_2023-05-19_at_4.35.53_PM.png",
null,
"Example response where metadata is used to control additional paywall variables"
],
"align": "center",
"sizing": "450px",
"caption": "Example response where metadata is used to control additional paywall variables"
}
]
}
[/block]

How to add metadata to your Offering

First, navigate to the Offering you'd like to add metadata to and click Edit.

No Metadata

Then begin adding valid JSON in the Metadata field for your Offering.

Editing

Once you've entered your desired JSON object, click Save to save your changes.

Saving

After saving your changes, you'll be navigated back to the summary page for your Offering, where the new metadata JSON object you've created will be displayed. (NOTE: Objects will be alphabetically ordered)

Saved

📘

When creating a new Offering, you'll be able to define a JSON object directly from the creation form.

Creating a JSON object

  • Offering metadata will automatically detect and support any valid JSON data type (booleans, strings, arrays, etc).
  • Nested objects can be used to group together similar keys.

Accessing metadata from your app

You can access metadata directly from the Offering object in the RevenueCat SDKs.

let offerings = try await Purchases.shared.offerings()
if let offering = offerings?.current {
    let paywallTitle = offering.getMetadataValue(for: "title", default: "Get Pro")
    let paywallSubtitle = offering.getMetadataValue(for: "subtitle", default: "Unlock all the features")
    let paywallButton = offering.getMetadataValue(for: "button", default: "Redeem Trial")
}
Purchases.sharedInstance.getOfferingsWith({ error ->
    // An error occurred
}) { offerings ->
    offerings.current?.let {
        val paywallTitle = it.getMetadataString("title", default="Get Pro")
        val paywallSubtitle = it.getMetadataString("title", default="Unlock all the features")
        val paywallButton = it.getMetadataString("title", default="Redeem Trial")
    }
}

Offering metadata limits

  • Offering metadata has a max limit of 4000 characters for the JSON object. If you reach that limit, you'll see an error when you attempt to save the Offering.

Offering metadata use case examples

You can find some example use cases in action in our Offering Metadata example use cases doc.

temp/offering-override.md

See contents

You can override the current offering that displays in your app on a per-user basis by selecting a different offering in the Current Offering card. This can be useful for:

  • Testing your dynamic paywall in sandbox without affecting your production app by changing the current offering for your own sandbox user. This is especially important for testing your offerings for Experiments.
  • Overriding the current offering for a customer in order to give them access to a specific in-app purchase that isn't otherwise available to the rest of your user base, as in the case of offering discounts in a customer support setting.

In the card, you'll see the current offering for the user:
[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/b643d4f-app.revenuecat.com_customers_aec1bada_15343510_1.png",
"app.revenuecat.com_customers_aec1bada_15343510 (1).png",
390,
154,
"#000000"
],
"caption": "This user's current offering is the same as the one selected for the project that contains your app."
}
]
}
[/block]
Click on edit to choose a new offering:
[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/7082b16-Screen_Shot_2020-07-30_at_12.56.07_PM.png",
"Screen Shot 2020-07-30 at 12.56.07 PM.png",
722,
492,
"#eae7e6"
],
"caption": "Let's give this user access to the sale offering."
}
]
}
[/block]

temp/singular.md

See contents

With our Singular integration you can:

  • Accurately track subscriptions generated from Singular campaigns, allowing you to know precisely how much revenue your campaigns generate.
  • Send trial conversions and renewals directly from RevenueCat to Singular, allowing for tracking without an app open.
  • Continue to follow your cohorts for months to know the long tail revenue generated by your campaigns.

Integration at a Glance

Includes Revenue Supports Negative Revenue Sends Sandbox Events Includes Subscriber Attributes Sends Transfer Events Optional Event Types
Requires sandbox SDK key Certain Reserved Attributes only non_subscription_purchase_event
uncancellation_event
subscription_paused_event
expiration_event
billing_issues_event
product_change_event

1. Send device data to RevenueCat

The Singular integration requires some device-specific data. RevenueCat will only send events into Singular if the below Subscriber Attributes keys have been set for the device.

Key Description Required
$idfa iOS advertising identifier UUID ✅ (iOS only)
$idfv iOS vender identifier UUID ✅ (iOS only)
$gpsAdId Google advertising identifier ✅ (Android only)
$androidId Android device identifier ✅ (Android only)
$ip The IP address of the device ❌ (optional)

These properties can be set manually, like any other Subscriber Attributes, or through the helper method collectDeviceIdentifiers().

import AdSupport
// ...
Purchases.configure(withAPIKey: "public_sdk_key")
// ...
Purchases.shared.attribution.collectDeviceIdentifiers()
//..
Purchases.configure(this, "public_sdk_key")
//..
Purchases.sharedInstance.collectDeviceIdentifiers()

You should make sure to call collectDeviceIdentifiers() after the Purchases SDK is configured, and before the first purchase occurs. It's safe to call this multiple times, as only the new/updated values will be sent to RevenueCat.

📘 Modifying SKAdNetwork conversion values is not currently supported

Since we deliver server-to-server events via API, as opposed to on the SDK where SKAN can be interacted with, this integration does not allow you to modify SKAN conversion values directly from received RevenueCat events.

❗️ Device identifiers with iOS App Tracking Transparency (iOS 14.5+)

If you are requesting the App Tracking permission through ATT to access the IDFA, you can call .collectDeviceIdentifiers() again if the customer accepts the permission to update the $idfa attribute in RevenueCat.

📘 Import AdSupport Framework (iOS)

The AdSupport framework is required to access the IDFA parameter on iOS. Don't forget to import this into your project.

❗️ Remove any client-side purchase tracking

Make sure to remove all client-side tracking of revenue. Since RevenueCat will be sending events for all revenue actions, tracking purchases with the Singular SDK directly can lead to double counting of revenue in Singular.

(Optional) Send campaign data to RevenueCat

RevenueCat itself is not an attribution network, and can't determine which specific ad drove an install/conversion. However, if you're able to collect this information from another source, such as Singular, it's possible to attach it to a user in RevenueCat using Subscriber Attributes as well.
The below reserved key names can be used to optionally attach campaign data to a user. This data will then be sent through to other downstream analytics integrations and accessible via APIs and webhooks.

Key
$mediaSource
$campaign
$adGroup
$ad
$keyword
$creative

2. Send RevenueCat events into Singular

After you've set up the Purchases SDK to send device data to RevenueCat, you can "turn on" the integration and configure the event names from the RevenueCat dashboard.

🚧 Enable Reject IAP without Receipt on Singular

RevenueCat's events do not include receipts from the stores, since we validate purchases before triggering events. Therefore, do NOT enable the 'Reject IAP without Receipt' setting in Singular's Apps page so these events are accepted in Singular after having been validated by RevenueCat.

  1. Navigate to your project in the RevenueCat dashboard and find the Integrations card in the left menu. Select + New

  1. Choose Singular from the Integrations menu.
  2. Add your Singular SDK key, and an optional sandbox SDK key. The sandbox SDK key will be used for any sandbox purchases so you don't have to send them to your production Singular instance.
  3. Enter the event names that RevenueCat will send or choose the default event names.
  4. Select whether you want RevenueCat to report proceeds (after app store cut) or revenue (gross sales).
Screenshot 2023-11-21 at 11 38 23 AM

3. Testing the Singular integration

You can test the Singular integration end-to-end before going live. It's recommended that you test the integration is working properly for new users, and any existing users that may update their app to a new version.

Add a sandbox SDK key in the RevenueCat dashboard

Before you test the integration, make sure you have a Singular SDK key set in the "Sandbox API key" field in RevenueCat. This is required if you want the integration to trigger for sandbox purchases.

Make a sandbox purchase with a new user

Simulate a new user installing your app, and go through your app flow to complete a sandbox purchase.

Check that the required device data is collected

Navigate the the Customer View for the test user that just made a purchase. Make sure that all of the required data from step 1 above is listed as attributes for the user.

🚧 Check the Singular Export logs tool for Revenue

While we are still sending the Revenue metric for these events, you should not expect to see the event appearing as Revenue in Singular's SDK console. You can verify that the event is processed as a revenue event in the Export logs tool via Singular.

Check that the Singular event delivered successfully

While still on the Customer View, click into the test purchase event in the Customer History and make sure that the Singular integration event exists and was delivered successfully.

👍 You've done it!

You should start seeing events from RevenueCat appear in Singular

temp/targeting.md

See contents

Targeting allows you to create rules for serving distinct audiences their own Offering. Instead of having a single Default Offering that all customers receive, you can instead create a cascading sequence of rules to deliver different Offerings to each audience that you define.

This allows you to create paywall experiences that are tailored to each of your audiences so you can make an effective pitch for your product and maximize lifetime value.

📘

Targeting is available on Pro, Scale, and Enterprise plans. Click here to review your plan and consider upgrading.

Terminology

Term Definition
Offering The set of Packages, metadata, and an optional paywall UI you can create to remotely control your paywall experience.
Default Offering The Offering that is set as "Default" in the RevenueCat Dashboard. We recommend designing your app so that the paywall always shows the Default Offering so that you can remotely control which Offering is presented.
Targeting The ability to assign a distinct Offering to a distinct audience of customers based on Targeting Rules you create.
Targeting Rule A collection of conditions that, when they are true for a given customer, will result in that customer matching the rule and being served the corresponding Offering.
Conditions The filters such as App, Country, and App Version that can be used to construct a Targeting Rule.
Audience The customers who would be included in a Targeting Rule due to matching its conditions.
Live The Targeting Rules that are actively being used to determine which customers which receive Offerings, as determined by their conditions, assessed in order from top to bottom.
Inactive The Targeting Rules that are not actively being used. These may be drafts, rules you previously used, rules you intend to set live in the future, etc.

How Targeting works

Before you setup any Targeting Rules, if you use Offerings, here's how they're returned in your app:

  1. RevenueCat is initialized
  2. Offerings are fetched
  3. Your list of Offerings is returned, along with the identifier of the Current Offering for a given customer

As long as your app is setup to display a customer's Current Offering on your paywall, then you can change the Default Offering that gets provided for a customer at any time from our Dashboard, or run an Experiment to serve two different Offerings to specific audiences.

Once you setup Targeting Rules, you unlock an additional level of customization, because the Current Offering that gets returned for each customer will be based on the Rule they qualify for. For example:

  1. RevenueCat is initialized
  2. Offerings are fetched
  3. Your list of Offerings is returned, along with the identifier of the Offering for the first rule that the customer matched as the Current Offering for that customer
    1. If the customer does not match any specified rules, they'll receive the Default Offering for your Project.

🚧

In December 2023 we began referring to a Project's current Offering as it's default Offering. Learn more here.

When determining which (if any) Targeting Rule a customer matches, we'll assess them from top to bottom as you've ordered them in the Dashboard.

Creating Targeting Rules

First, navigate to "Targeting" in the "Monetization tools" section of your Project Settings. Then click on "Create a New Rule" to begin.

[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/8fa325b-Create_a_new_rule_w_highlight.png",
null,
""
],
"align": "center"
}
]
}
[/block]

Then, create your rule by:

  1. Entering a Display name
  2. Selecting your desired conditions (learn more about conditions here)
  3. Selecting an Offering to display when those conditions are met
  4. Selecting your desired State for the rule

[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/bce5dfd-Ready_to_save_w_highlight.png",
"",
""
],
"align": "center"
}
]
}
[/block]

Once you've entered all of the required fields for your rule, click "Save" and it will be added to the list of rules in State you've selected.

Ordering Targeting Rules

How Live rules are added to the list

When a rule is newly set Live (either when it's created or when an Inactive rule is set Live), it'll be ordered at the bottom of that list so that if its targeted audience has any overlap with other Live rules, the existing Live rules will "outrank" the new rule when determining what a customer receives.

📘

Live rules can be reordered at any time.

Ordering Live rules

  1. Click "Order rules" to enter the ordering mode
  2. Drag the rules you wish to reorder to their correct location in the list
  3. Click "Save order" when you've set them in the order you'd like them to be evaluated in

[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/f18a508-Ordering.png",
"",
""
],
"align": "center"
}
]
}
[/block]

📘

Ordering only applies to cases where 1 customer may match multiple Live rules. If your Live rules are mutually exclusive, their order will have no impact on how customers are assigned to them.

Editing, deleting, and more

Rules can also be edited, deleted, or made inactive at any time if you need to modify how Offerings are being served to your customers.

[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/41120c1-Rule_actions.png",
"",
""
],
"align": "center"
}
]
}
[/block]

In addition, if you're looking to add a new rule that's similar to an existing one, you can start by duplicating that rule and then making the desired modifications to the rule conditions.

Learn more about conditions

Definitions

[block:parameters]
{
"data": {
"h-0": "Dimension",
"h-1": "Definition",
"0-0": "Country",
"0-1": "The storefront a customer is currently using on supported SDK versions\*, or their geolocation otherwise. \n \n\*Available on iOS SDK Versions >= 4.30.4 with support on other SDKs coming soon",
"1-0": "App",
"1-1": "The App that your customer is currently using. Commonly used to target by platform.",
"2-0": "App Version",
"2-1": "The App Version of a specified App that your customer is currently using. When assessing App Versions using more than or less than operators, we apply semantic versioning logic.",
"3-0": "RC SDK Version",
"3-1": "The RC SDK Version of a specified SDK flavor of the App Versions that your customer is currently using. Commonly used to target RevenueCat Paywalls only to RC SDK Versions that explicitly support them. "
},
"cols": 2,
"rows": 4,
"align": [
"left",
"left"
]
}
[/block]

How conditions interact with each other

  • Dimensions like Country and App which have a defined set of possible values can be added with an "is any of" or "is not any of" to select individual values or sets of values to include/exclude
  • Dimensions like App Version and RC SDK Version which have an ever expanding set of possible values can be added with is/is not or more than/less than operators.
  • Multiple conditions can be added for each dimension with an AND relationship, to create rules such as:
    • App version is more than or equal to 1.1.0
    • App version is less than 1.2.0
  • App Version and RC SDK Version must always have a specified App or SDK (respectively), since the intended version to target may be different between the App or RC SDK flavor you're targeting.

[block:image]
{
"images": [
{
"image": [
"https://files.readme.io/bb9ce00-App_version_filter.png",
"",
"When filtering by App version, select the App that the filter applies to first."
],
"align": "center",
"caption": "When filtering by App version, select the App that the filter applies to first."
}
]
}
[/block]

FAQs

[block:parameters]
{
"data": {
"h-0": "Question",
"h-1": "Answer",
"0-0": "How do Targeting and Experiments interact?",
"0-1": "TL;DR Experiment enrollment is checked before Targeting Rules are assessed \n \nWhen any Experiment is running, customer enrollment will occur before Offerings are fetched. When Offerings are fetched, we'll first check to see if a customer is enrolled in a running Experiment. If they are, their variant's Offering will be returned. If they are not, then any existing Targeting Rules will be assessed.",
"1-0": "How can I test Targeting in my app?",
"1-1": "The easiest way to test Targeting is to create a Targeting Rule for an app version that has not yet been released (e.g. only available in TestFlight), and serve a unique Offering to that Targeting Rule. Then, check to confirm whether your app in production displays a different Offering than your app version in TestFlight does."
},
"cols": 2,
"rows": 2,
"align": [
"left",
"left"
]
}
[/block]

Copy link
Member

@joshdholtz joshdholtz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's ship this!

@joshdholtz joshdholtz merged commit 90d0772 into main Dec 5, 2023
10 checks passed
@joshdholtz joshdholtz deleted the change-current-offering-to-default branch December 5, 2023 20:44
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants