Overview: a sale that runs itself

Black Friday falls on November 27, 2026, and Cyber Monday on November 30. A holiday sale is worth planning for: in RevenueCat's analysis of holiday offers across 5,000 apps, "for the median app, buyers acquired on an average sale day generate 12% more revenue over six months than buyers acquired on an average baseline day. Overall, 60% of apps come out ahead."

The hard part is rarely the discount itself. It is starting the sale on time, ending it on time, and not shipping an app update for either. In this codelab you set the sale up once, ahead of time: the stores hold the discount, and RevenueCat switches the sale paywall on and off:

  1. Pick a store discount that does what you want: discount the first period, or lock in a lower renewal price.
  2. Put the sale products and a countdown paywall in a dedicated holiday Offering.
  3. Fetch the paywall by placement in your app, so the dashboard decides what each screen shows.
  4. Create a scheduled Targeting rule that serves the holiday Offering between a start time and an end time. Outside that window, customers get your regular paywall again.

Before you start

  • A RevenueCat project with products, an Offering, and a paywall already working. If you are not there yet, start with the iOS, Android, Flutter, React Native, or Kotlin Multiplatform codelab.
  • Targeting, which this codelab is built on, is available on RevenueCat's Pro and Enterprise plans.
  • An SDK version that supports what you use. Using the latest release of each SDK is the simplest choice.
FeatureiOSAndroidFlutterReact NativeKMP
Placements4.38.07.7.06.24.07.24.01.2.0+13.6.0
Paywall Countdown component5.48.09.14.09.9.79.6.62.2.9+17.18.1
Start two weeks early. A new App Store product has to pass review before it can be sold in production, and RevenueCat's docs note that in-app purchase review times "may vary from a few hours to a few days, and sometimes more." Everything else in this codelab can be prepared the same day, but give the store side time.

Choose how the discount works

First decide what the customer actually gets, because each store mechanism behaves differently after the sale. There are two useful shapes:

  • Discount the first period, then the regular price. A new subscriber pays less for the first month or year and then renews at your normal price.
  • Lock in the sale price. The subscriber keeps renewing at the discounted price for as long as they stay subscribed.
MechanismWho can get itAfter the offerUse it for a holiday sale?
App Store introductory offer with start and end datesCustomers who have not used an introductory offer in this subscription group yet. Anyone who already took your free trial cannot get it. Renews at the regular priceYes, for "first period off"
Google Play offer on the base planNew customer acquisition offers go to eligible new customers. A developer determined offer is available to every buyer, so the RevenueCat SDK can apply it automatically on any paywall, unless you tag it rc-ignore-offer and select it by offer ID.Renews at the regular priceYes, for "first period off"; you activate and deactivate it by hand
Separate discounted product (App Store) or holiday base plan (Google Play) Anyone your Targeting rule shows it toKeeps renewing at the sale priceYes, for "lock in the price"
Lowering your subscription's priceEveryone, including current subscribersApple: "existing subscriptions will automatically renew at the lower price", and a decrease "can't be reversed"No
App Store promotional offerOnly existing and previously subscribed customers Regular priceNot for new users; useful for a win-back sale to lapsed subscribers
App Store offer codePeople who enter a code or open a redemption link Depends on the offerFor email or social campaigns; a paywall can only add a Redeem code button, it cannot apply the code by itself

For most apps the simplest setup is: an introductory offer on the App Store and an offer on Google Play, on the products you already sell, plus a holiday Offering whose paywall announces the sale and counts down to the end. If you want subscribers to keep the sale price, use separate products instead. RevenueCat's App Store holiday sale guide spells out the trade-off: these products "must now live on forever", so it recommends creating products for fixed discount rates (20%, 30%, and so on) and reusing them for later sales.

Do not cut your base price for the sale. On the App Store a price decrease applies to every existing subscriber at their next renewal, and Apple notes that once it takes effect it "can't be reversed". Raising the price again afterwards is a price increase, with its own notice and consent rules.

Set up the discount in each store

App Store: a scheduled introductory offer

In App Store Connect, add an introductory offer to the subscription and set its start and end dates. Keep these rules from Apple in mind:

  • "You can have one current and one future introductory offer per storefront." If the product already has a free trial, the holiday offer is the future one. Once it becomes current, you can schedule the offer that restores your regular trial after the sale.
  • "Once you create an introductory offer, you won't be able to edit it." Check the dates before you save.
  • "Offers end on the selected end date, with the exact time varying by region." The App Store side does not switch at a single global moment, which matters in step 9.
  • "New and returning customers are only eligible to use one introductory offer per subscription group." Customers who already used your free trial cannot get the holiday offer. If that is most of your audience, consider a separate sale product instead.

App Store: a separate sale product

If you chose to lock in the sale price, create a new auto-renewable subscription, for example pro_annual_30off, in the same subscription group as your regular plans. Apple lets a person hold only one subscription per group, which keeps someone from buying the sale plan on top of their regular one. Submit it for review early: until it is approved it works only in Sandbox and TestFlight, where it needs "Ready to Submit" status.

Google Play: an offer or a holiday base plan

  • Offer on your existing base plan. Add an offer with a discounted phase (fixed amount, percentage discount, or absolute discount) and pick its eligibility. Click Activate when the sale starts. Play Console has no end date for offers, so plan to deactivate it yourself; Google notes that "any existing subscribers will continue to benefit from the offer."
  • Holiday base plan. To lock in the price, add a base plan such as annual-holiday to your subscription. In RevenueCat it becomes its own product, <subscription_id>:<base-plan-id>. Deactivating it later "does not affect existing subscriptions."
A free trial can silently win over your discount. When you do not pick an offer yourself, RevenueCat chooses one with this rule: "Find the longest free trial the customer is eligible for", and "If there is no free trial, find the cheapest introductory period the customer is eligible for". If your base plan also has a free-trial offer, the holiday discount is not the one applied. Fix it by setting the package's Play Store offer ID on the sale paywall (step 5), or, before the sale starts, by adding the tag rc-ignore-offer to the trial offer (and removing it after the sale).

Create the holiday Offering

Your regular paywall stays untouched. The sale gets its own Offering, for example with the identifier holiday_2026, so switching between them is only a matter of which Offering RevenueCat serves.

Packages

  • Introductory offer or Play offer: add the same products your default Offering uses. The discount comes from the store, so the products do not change.
  • Separate sale products: add the sale products, and also add your standard products as extra packages, for example annual_promo and monthly_promo next to annual_standard and monthly_standard. The countdown paywall in the next step shows the standard ones once the timer runs out, and the docs require that "all packages must be included in the offering used by the paywall."

Metadata

Offering metadata is a free-form JSON object (up to 4,000 characters) that your app can read. The docs suggest a similar use: a key like discount_message that, "if present, shows a special message about the applied discount on the paywall." Add something like:

json
{
  "sale_name": "Black Friday",
  "sale_ends_at": "2026-12-01T08:00:00Z",
  "discount_message": "Black Friday sale"
}

Your regular Offering does not have these keys, so the same app code can show a sale banner only while the holiday Offering is the one being served. You read them in step 6.

Keep prices and percentages out of metadata. Metadata is the same for everyone who gets the Offering, and it cannot tell whether this customer is eligible for the store offer. Someone who already used an introductory offer would see "40% off" and then pay full price. Put the discount in paywall text with offer variables (step 5), which only show an offer that is actually there.

Build the sale paywall with a countdown

Start from a copy of your regular paywall so the sale still looks like your app, and attach it to the holiday Offering. Then add the parts that make it a sale.

Countdown component

Paywalls V2 has a Countdown component that shows "a live countdown timer to a specific date and time." Set its Target Date to the same moment your sale ends. The date is in UTC, the same as the Targeting rule in step 7. Inside the countdown, text components can use {{ count_days_without_zero }}, {{ count_hours_with_zero }}, {{ count_minutes_with_zero }}, and {{ count_seconds_with_zero }}.

  • Countdown Stack: what shows while the timer runs. With separate sale products, put the annual_promo and monthly_promo packages here.
  • End Stack: "When the countdown reaches zero, the component automatically switches to display the End Stack content." Put the standard packages or your regular copy here, so a customer who keeps the paywall open past the deadline never sees an expired price.
  • Fallback Stack: older SDKs do not render the countdown at all, so the docs recommend enabling the fallback stack. Check the version table in step 1.
The countdown counts to a fixed date. It cannot count down from the moment the paywall opens; the docs say that is "not yet supported". For a holiday sale with a fixed end, that is exactly what you want.

Price text that never lies

For introductory offers and Play offers, write the sale price with offer variables such as {{ product.offer_price }} and {{ product.offer_period_with_unit }}, inside a rule on the offer type. The editor refuses to save offer variables outside such a rule ("Use offer variables only in rules where an offer is present."), so a customer who is not eligible sees the regular price instead of a discount they cannot get. Avoid {{ product.relative_discount }} for "X% off" copy: it compares packages on the same paywall, not the sale price with your pre-sale price.

Point Google Play packages at the holiday offer

On each package component, the Promotional Offer section has a Play Store field: "Set this to the offer ID of the Play Store offer you'd like to apply." This is the fix for the free-trial problem from step 3. If the offer is not available to a customer, the SDK falls back to its default choice. It needs Android SDK 9.21.0, Flutter 9.12.0, React Native 9.9.0, or KMP 2.6.0+17.41.0 or later. On older SDKs the offer ID is ignored and the free-trial rule from step 3 applies again.

With an offer ID set, customers eligible for that offer see the package's Promotional offer text, because a promotional offer "will take precedence over the introductory offer". Put the sale copy in the Promotional offer rule as well as the Introductory offer rule.

Show the right paywall with placements

A placement is a name for a spot in your app where a paywall appears, such as upgrade_button or onboarding_end. Your app asks for the Offering for a placement, and the Targeting rule decides the answer. During the sale it is the holiday Offering; before and after, it is your regular one. The code never changes.

swift
import SwiftUI
import RevenueCat
import RevenueCatUI

struct UpgradeButton: View {
    @State private var offering: Offering?

    var body: some View {
        Button("Go Premium") {
            Task {
                do {
                    // While the sale rule is live, this placement returns the holiday
                    // Offering. Before and after, it returns your regular one.
                    let offerings = try await Purchases.shared.offerings()
                    // nil means the rule set "No Offering" for this placement.
                    offering = offerings.currentOffering(forPlacement: "upgrade_button")
                } catch {
                    print("Offerings fetch failed: \(error)")
                }
            }
        }
        .sheet(item: $offering) { offering in
            PaywallView(offering: offering, displayCloseButton: true)
        }
    }
}
kotlin
import android.util.Log
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.awaitOfferingsResult
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialog
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions
import kotlinx.coroutines.launch

@Composable
fun UpgradeButton() {
    val scope = rememberCoroutineScope()
    var offering by remember { mutableStateOf<Offering?>(null) }

    Button(onClick = {
        scope.launch {
            // While the sale rule is live, this placement returns the holiday
            // Offering. Before and after, it returns your regular one.
            // null means the rule set "No Offering" for this placement.
            offering = Purchases.sharedInstance.awaitOfferingsResult()
                .onFailure { Log.w("Paywall", "Offerings fetch failed", it) }
                .getOrNull()
                ?.getCurrentOfferingForPlacement("upgrade_button")
        }
    }) { Text("Go Premium") }

    offering?.let {
        PaywallDialog(
            PaywallDialogOptions.Builder()
                .setOffering(it)
                .setDismissRequest { offering = null }
                .build()
        )
    }
}
dart
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';

Future<void> showUpgradePaywall() async {
  try {
    // The placement identifier is a positional argument.
    final offering =
        await Purchases.getCurrentOfferingForPlacement('upgrade_button');
    // null means the rule set "No Offering" for this placement.
    if (offering == null) return;
    await RevenueCatUI.presentPaywall(offering: offering);
  } on PlatformException catch (e) {
    debugPrint('Could not load the paywall: ${e.message}');
  }
}
typescript
import Purchases from 'react-native-purchases';
import RevenueCatUI from 'react-native-purchases-ui';

export async function showUpgradePaywall() {
  try {
    // getOfferings rejects when the fetch fails. On iOS,
    // getCurrentOfferingForPlacement resolves null instead, so fetch first
    // to surface the error.
    await Purchases.getOfferings();
    // While the sale rule is live, this placement returns the holiday
    // Offering. Before and after, it returns your regular one.
    const offering = await Purchases.getCurrentOfferingForPlacement('upgrade_button');
    // null means the rule set "No Offering" for this placement.
    if (!offering) return;
    await RevenueCatUI.presentPaywall({ offering });
  } catch (e) {
    console.warn('Could not load the paywall', e);
  }
}
kotlin
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.ktx.awaitOfferings
import com.revenuecat.purchases.kmp.models.Offering
import com.revenuecat.purchases.kmp.models.PurchasesException
import com.revenuecat.purchases.kmp.ui.revenuecatui.Paywall
import com.revenuecat.purchases.kmp.ui.revenuecatui.PaywallOptions
import kotlinx.coroutines.launch

@Composable
fun UpgradeScreen() {
    val scope = rememberCoroutineScope()
    var offering by remember { mutableStateOf<Offering?>(null) }

    val current = offering
    if (current != null) {
        // Paywall is a full-screen composable, so it replaces this screen.
        Paywall(PaywallOptions(dismissRequest = { offering = null }) {
            this.offering = current
        })
    } else {
        Button(onClick = {
            scope.launch {
                offering = try {
                    // In KMP, getCurrentOfferingForPlacement is on Offerings.
                    // null means the rule set "No Offering" for this placement.
                    Purchases.sharedInstance.awaitOfferings()
                        .getCurrentOfferingForPlacement("upgrade_button")
                } catch (e: PurchasesException) {
                    println("Offerings fetch failed: ${e.message}")
                    null
                }
            }
        }) { Text("Go Premium") }
    }
}
  • Handle a missing Offering. A rule can set a placement to No Offering, which returns null, and a failed fetch also leaves you without an Offering. The samples show nothing in both cases and log the fetch error, so a network or configuration problem does not fail silently.
  • Already using offerings.current? It keeps working. When a rule uses placements, current returns the rule's Offering for "all other cases". Placements are what let you run the sale on some screens and not others.

Read the sale metadata

To show your own sale banner outside the paywall, read the metadata from step 4:

swift
// Empty string when the key is missing, for example on your regular Offering.
let saleName = offering.getMetadataValue(for: "sale_name", default: "")
let saleEndsAt = offering.getMetadataValue(for: "sale_ends_at", default: "")
kotlin
// Empty string when the key is missing, for example on your regular Offering.
val saleName = offering.getMetadataString("sale_name", "")
val saleEndsAt = offering.getMetadataString("sale_ends_at", "")
dart
// null when the key is missing, for example on your regular Offering.
final saleName = offering.metadata['sale_name'] as String?;
final saleEndsAt = offering.metadata['sale_ends_at'] as String?;
typescript
// Metadata values are typed as unknown, so cast them.
const saleName = offering.metadata['sale_name'] as string | undefined;
const saleEndsAt = offering.metadata['sale_ends_at'] as string | undefined;
kotlin
// Empty string when the key is missing, for example on your regular Offering.
val saleName = offering.getMetadataString("sale_name", "")
val saleEndsAt = offering.getMetadataString("sale_ends_at", "")
Metadata is not a paywall variable. Paywall text cannot read Offering metadata directly. If you want a metadata value inside a RevenueCat paywall, pass it in as a custom variable ({{ custom.variable_name }}) when you present the paywall.

Schedule the Targeting rule

This rule starts and ends the sale paywall. The docs call out this exact use: scheduling "is especially useful for running promotions, holiday offers, or other sales that have a time-limited component to them."

The rule controls the paywall, not the store price. An App Store introductory offer applies to eligible buyers on any paywall during its own dates, and a Google Play offer stays live until you deactivate it. Line those store dates up with this rule's window, and use offer variables on your regular paywall too, so its price text stays right while a store offer is live.
  1. In the RevenueCat dashboard, open Targeting and create a new rule named, for example, Black Friday 2026.
  2. Audience. Choose who sees the sale. A common choice is customers who are not currently subscribed: add a Subscription status filter that excludes Active and Trialing, so current subscribers are not shown a cheaper plan. Targeting does not offer an Active entitlements filter. Check that customers who never subscribed still match the filter you build. Note that "a rule targeting All customers needs at least one Placement, and only one such rule can be Live."
  3. Offering and placements. Serve holiday_2026, and map each placement that should show the sale (for example upgrade_button) to it.
  4. Schedule. "Select Scheduled as the State for the Rule", then "enter a Start time and End time" and save. For a sale from midnight Pacific time on Black Friday until midnight after Cyber Monday, enter 2026-11-27 08:00 to 2026-12-01 08:00.
  5. Order. Drag the rule above any broader rule. RevenueCat checks rules "from top to bottom" and serves the first match, and a newly scheduled rule is placed at the bottom of the list.
Times are UTC. The docs are explicit: "Double check that you've entered your desired time in UTC to make sure it's scheduled correctly." Midnight in New York, Seoul, and Berlin are three different UTC times. Use the same UTC end time for the rule, the countdown Target Date, and the sale_ends_at metadata.
Experiments come first. "Experiment enrollment is checked before Targeting Rules are assessed", so customers enrolled in a running Offering experiment keep seeing their experiment variant instead of the sale. Stop or plan around experiments that overlap the sale.

A scheduled rule changes nothing until its start time: "Scheduled rules have no impact on what Offerings are served until they become Live." You can set it up today and leave it.

Test the sale before it goes live

Never let the first real customer be your first test. Two ways to see the holiday Offering early without showing it to anyone else:

  • An unreleased app version. The docs' suggestion: "create a Targeting Rule whose audience filters on an App version that has not yet been released (e.g. only available in TestFlight), and serve a unique Offering to that Targeting Rule." The filter uses the version string, and "the build number is never used for Targeting."
  • A custom attribute for testers. Set an attribute such as qa_holiday on your test accounts (see Subscriber Attributes), then create a Live rule whose audience is that attribute and move it to the top of the list (new rules are added at the bottom). The attribute key must be set for at least one customer before it appears in the rule builder. Delete this rule before the sale starts.

Attributes normally sync when the app goes to the background. To fetch the new Offering right away, call syncAttributesAndOfferingsIfNeeded() after setting the attribute; it is limited to 5 calls per minute. For one-off checks, a customer's profile in the dashboard can also override that customer's current Offering (offerings.current). Check that your placement returns it too before relying on this for a placement test.

Store sandboxes

  • Apple: in Sandbox and TestFlight, "prices often won't reflect the actual prices set in App Store Connect", and changes to an introductory offer can take up to an hour to appear in sandbox.
  • Google Play: use license testers. Renewals are accelerated, so a yearly plan renews about every 30 minutes.
  • Test Store is great for checking the paywall layout, but only in debug builds: "TestFlight builds and Google Play internal, closed, and open testing tracks are release builds, so a Test Store API key crashes them too."

Pre-launch checklist

  • The rule, the countdown Target Date, and sale_ends_at all use the same UTC end time.
  • The rule sits above every broader rule, and no running experiment covers the same customers.
  • The App Store offer dates and the Google Play offer activation line up with the rule's window, and the regular paywall uses offer variables for its price text.
  • App Store sale products are approved, and Google Play offers are ready to activate.
  • Google Play packages on the sale paywall point at the holiday offer ID.
  • The tester rule is deleted.

Launch day and the automatic end

At the start time the rule goes Live. RevenueCat "evaluates a rule's audience each time your app fetches Offerings", so customers in the audience get the holiday Offering on their next fetch. At the end time the window closes, and the same fetch returns whatever the next matching rule serves, or your Default Offering. Nobody ships an update.

Why the switch takes a few minutes

The SDKs cache Offerings, so a running app does not see the change at the exact second:

  • While the app is in the foreground, the cache is refreshed after 5 minutes. A stale copy is returned once while the refresh happens in the background.
  • When the app comes back to the foreground with a stale cache, it refreshes.
  • The cache is kept in memory, so a cold start requests fresh Offerings. If that request fails with a network or server error, the SDK falls back to the last copy saved on disk.

This is why the countdown's End Stack matters: the paywall switches to your regular packages and copy at the exact end time, even in an app that is still holding the holiday Offering a little longer.

The store side

  • App Store introductory offers end on their own, at a time that varies by region.
  • Google Play offers and holiday base plans do not. Deactivate them when the sale ends.
  • Separate sale products must stay available: subscribers who bought during the sale keep renewing at the sale price.

Measure the sale

RevenueCat Charts can separate sale traffic from regular traffic:

  • Revenue can be segmented by Offering, placement, or Targeting rule, which isolates what the holiday Offering earned.
  • Initial Conversion can be filtered by Offering. It is a filter, not a segment, and the docs note that the New Customers count does not change when you filter by Offering, because those filters only apply to subscribers.
  • Paywall Conversion can be segmented by Offering and paywall. It only counts RevenueCat Paywalls.
  • The Offer and Offer type dimensions separate purchases that used an introductory offer from full-price ones.

The number that matters most is not the sale week's revenue but what those customers are worth later. The benchmark from step 1 measured revenue over six months, so revisit the holiday cohort in spring before you decide on next year's discount.

Want to test two discounts? Experiments can A/B test two holiday Offerings (for example 30% and 50% off) on Pro and Enterprise plans. Experiments do not have a schedule: you start and stop them by hand. When stopped, enrolled customers "will begin receiving the Default Offering on their next paywall view."

Recap

You now have a sale paywall that starts and ends on schedule without an app release:

  • The store discount fits the goal: an introductory offer or Play offer for "first period off", separate products for a locked-in price, and never a base price cut.
  • A holiday Offering holds the sale packages, metadata, and a countdown paywall with an End Stack.
  • The app asks for an Offering by placement, so the dashboard decides what each screen shows.
  • A scheduled Targeting rule, in UTC and ordered above broader rules, serves the sale only inside its window.

After the sale

  • Deactivate Google Play offers and holiday base plans, and keep separate App Store sale products available.
  • Keep the Offering and paywall: next year you mostly change dates.

Further reading

Keep going