<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title>RevenueCat Blog</title>
    <link>https://www.revenuecat.com/blog</link>
    <description>Insights about subscription apps, monetization and growth — by RevenueCat.</description>
    <language>en-US</language>
    <lastBuildDate>Wed, 05 Aug 2026 09:36:33 GMT</lastBuildDate>
    <atom:link href="https://www.revenuecat.com/blog/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title><![CDATA[His crowdfunding round was oversubscribed in 24 hours. He shut it down at 3x the target.]]></title>
      <link>https://www.revenuecat.com/blog/growth/jelte-liebrand-savvy-navvy-sub-club-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/jelte-liebrand-savvy-navvy-sub-club-podcast-2026</guid>
      <pubDate>Wed, 05 Aug 2026 09:36:33 GMT</pubDate>
      <dc:creator><![CDATA[David Barnard]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Jelte Liebrand walked away from a VC term sheet, then turned his app's own users into 2,500 investors — and his first piece of advice to founders considering the same path is "don't raise at all."]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/ab9f80564c2677c409bbd2d092ff4f52686e94f0-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>When Savvy Navvy — the &quot;Google Maps for boats&quot; — needed capital to push into marketing, founder Jelte Liebrand had already been down the standard path. A Google pitch event led to VC conversations, flights, and a term sheet. But the friction was there before the ink: &quot;They want one thing from the business, we want something else.&quot; Both sides walked away.</p>
<p><a href="https://www.youtube.com/watch?v=sKonOtcJFTU">Watch on YouTube</a></p>
<p>A year later, an angel-turned-customer asked a question that changed the company's trajectory: have you considered crowdfunding? With 10,000 boaters already using the app, Liebrand launched an equity crowdfunding campaign with a target of £125,000 and no idea what to expect.</p>
<p>&quot;Within 24 hours we're oversubscribed,&quot; he says. &quot;Within six days we shut it down because it was triple of what we needed.&quot;</p>
<p>Savvy Navvy has repeated the playbook several times since, with most rounds landing around the £1 million mark: two or three angels at six figures each, then a long tail of customers investing anywhere from $10 to hundreds of thousands. The result is 2,500 investors who don't just fund the business — they answer logistics questions, make marketing introductions, and jump on calls. &quot;We basically have this army of investors who A, believe in what we do, B, can back us with money, but also back us with knowledge.&quot;</p>
<h2><strong>&quot;It's not some magic money tree. You're selling your business.&quot;</strong></h2>
<p>For all the success of the crowdfunding rounds, Liebrand is blunt about fundraising itself. &quot;I actually hate the phrase raising money,&quot; he says. &quot;It's not some magic money tree that you put some water in and you're raising this magical free money. You're selling your business.&quot;</p>
<p>His advice to founders who ask him about crowdfunding is even blunter: &quot;My first bit of advice is don't raise at all. If you can get away with not raising VC or crowd or angel or anything else, just don't.&quot; And for those who do: raise half of what you were planning to.</p>
<p>The numbers behind Savvy Navvy's rounds are public on the platform, and they're a useful reality check. The last round valued the company at £15 million against roughly $3.5 million in B2C ARR plus a couple million more in B2B revenue — a 3–4x multiple, not the 20x founders might fantasize about. As David points out in the episode, apps are typically acquired at around 4x trailing profit, which makes a 3–5x revenue multiple on a consumer app healthy, not stingy.</p>
<h2><strong>The 2-year subscription that transformed CAC payback</strong></h2>
<p>Asked for his biggest win of the past year, Liebrand doesn't hesitate: two-year subscriptions.</p>
<p>The mechanics are simple. Savvy Navvy's standard price is $129 per year; the two-year plan is $183 — roughly 30% off. Boat owners keep their boats for years, so the longer commitment fits how customers actually use the product. But the real payoff is timing: &quot;The benefit for us isn't actually, ooh, that's more money. It's more money upfront... that's had a huge impact on what we're then able to do on the marketing side and the spend that we can have, because we get that back immediately as we spend it.&quot;</p>
<p>If annual plans were the industry's answer to CAC payback, this is the same logic taken one step further — two years of guaranteed LTV, collected on day one.</p>
<h2><strong>The signup experiment that left scars</strong></h2>
<p>Liebrand's biggest fail of the year is one many growth teams will recognize. Account creation is a friction point, so the team tested removing it entirely — &quot;anonymous accounts&quot; created silently under the hood.</p>
<p>&quot;Initially the results were through the roof. It was amazing,&quot; he says. Then they rolled it out to 100%, and the success rate started to drop. Users who claimed to want privacy still wanted to sync between phone and iPad — which requires an account. Support tickets piled up. And metric issues meant the original uplift was overstated anyway. &quot;Internally people have scars from it.&quot;</p>
<p>His broader warning: without a billion users, most startups don't have the sample size to A/B test properly, and it's dangerously easy to read into the numbers what you want to see while something further down the funnel quietly breaks.</p>
<h2><strong>Word of mouth, but not every mouth is equal</strong></h2>
<p>Savvy Navvy runs a large program for boating instructors — no affiliate codes, no kickbacks. Many instructors explicitly don't want them, because they don't want to be seen as sales reps. Instead, they get free access, purpose-built teaching tools, and first crack at beta features.</p>
<p>Liebrand explains the logic with a story from his own training: an instructor teaching him to keep his thumbs clear of a winch — while missing a thumb from doing exactly that. &quot;I'm going to trust anything that guy says,&quot; he laughs. &quot;Word of mouth is a great thing for any business, but not every mouth is the same value.&quot;</p>
<p>The same thinking powers Savvy Navvy's B2B flywheel: partnerships with boat manufacturers, starting with electric boat maker Arc Boats, now put the app directly on helm displays — and an announcement timed to this episode brings CarPlay-style navigation to pontoon boats. When a manufacturer that has been building boats for decades ships your app on the dash, that's validation no ad budget can buy.</p>
<p>In <a href="https://www.youtube.com/watch?v=sKonOtcJFTU" target="_blank" rel="noopener noreferrer">the full episode</a>, Jelte and David also cover the clipboard-powered user research that revealed the real market, why running freemium in the US but not elsewhere gives the company two business models, and why &quot;if your product comes with a manual, you've already lost.&quot; Jelte also joins the Sub Club YouTube livestream on August 6th at 9:00 AM Pacific / 18:00 CET to take listener questions.</p>
<h2>Guest links</h2>
<p>•<a href="https://uk.linkedin.com/in/jelte-liebrand">Jelte Liebrand on LinkedIn</a></p>
<p>•<a href="https://www.savvy-navvy.com/">Savvy Navvy</a></p>
<p>
</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA["What do you want me for then?" The icon designer's dilemma in the age of AI]]></title>
      <link>https://www.revenuecat.com/blog/growth/matthew-skiles-icon-visual-designer-launched-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/matthew-skiles-icon-visual-designer-launched-podcast-2026</guid>
      <pubDate>Wed, 29 Jul 2026 12:38:31 GMT</pubDate>
      <dc:creator><![CDATA[Charlie Chapman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Matthew Skiles has spent 15 years hand-crafting the app icons you see all over WWDC videos — and he got his start with no portfolio, no formal training, and a lucky break.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/0550d6537a889bf30fe31650b0ae562c040ef48a-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Matthew Skiles is the designer behind an absurd number of indie app icons — including the Launched airplane itself, which he redrew when Charlie rebooted the show. Go to<a href="https://matthewskiles.com"> matthewskiles.com</a> and start scrolling, and you'll recognize icon after icon. His work shows up on the wall of icons in WWDC videos, and last year the Tide Guide icon he designed was held up on camera in Apple's WWDC rap video.</p>
<p><a href="https://www.youtube.com/watch?v=4GWNH4o91Mw">Watch on YouTube</a></p>
<p>None of it started with credentials. Skiles is a self-taught designer from the South Island of New Zealand who built his first website at 15 from an HTML book, coding a one-pager for a tennis coach with his brother. His first app icon was for Rock'n Rockets, an iPhone game the two brothers shipped themselves — covered twice on MacStories, played by maybe a hundred people.</p>
<p>When paid icon work finally arrived around 2014, it didn't come through a portfolio, because he didn't have one. &quot;My initial app icon work was people I already knew from other projects,&quot; he says. &quot;I didn't have the portfolio of any kind to show anyone that I can do app icons, but they knew my work already and they had enough faith in me to give it a shot.&quot; From there, the strategy was simple and stubborn: only post the icon work, never the web work, first on Dribbble and later on Twitter — until Christian Selig commissioned alternate icons for Apollo and put his name in front of a massive audience.</p>
<h2><strong>&quot;If I was a smart businessman, I wouldn't have made that decision&quot;</strong></h2>
<p>App icons are just about the worst consulting business you can pick: short engagements, little repeat work, a constant need for the next client. Charlie asked how he squares that, and Skiles didn't pretend there was a clever strategy.</p>
<p>&quot;If I was a smart businessman, I wouldn't have made that decision, but I make decisions based on it's fun,&quot; he says. &quot;It was not a smart business decision. I'm still not sure if it's a smart business decision, but it keeps working, so I'll keep doing it.&quot;</p>
<p>What makes it keep working is the scale of the App Store itself. After a career's worth of icons, he's still surprised: &quot;How can there be more apps and more people that I haven't worked with? But there's always a new person who comes along and says, 'I've got a new app and I want you to make an app icon.'&quot; It's a job that didn't exist when he was a kid — and it exists now because Apple built an ecosystem where developers are expected to care about how their icon looks.</p>
<h2><strong>The most common icon mistake: your symbol is too big</strong></h2>
<p>Skiles's process is more analog than you'd guess. He sketches roughly ten concepts in pencil on paper printed with half-icon grids, builds the winning shape in Illustrator, refines in Sketch, and only reaches for Photoshop when something needs texture — &quot;there's no better tool for building textured things than Photoshop,&quot; whatever people say about those apps being dead. Color comes last, only after the shape is settled.</p>
<p>His philosophy runs against the obvious instinct. The best icons, he argues, aren't literal representations of the app — think Transmit's truck or Coda's leaf. &quot;The app icon will make them be like, 'I'll give it a shot because I want that in my dock.'&quot; A utility you use every day should be something you like having around, the same way you'd rather own a nice pen than an ugly one.</p>
<p>And for anyone designing their own icon, he offers the single most common fix: shrink your symbol. People scale their logo up until it fills the squircle, and it almost never looks right. &quot;Just give it a bit of breathing room. Just shrink it down just a little bit and it'll look nicer.&quot; Even Apple's own grid circle is often too generous — &quot;you actually want to be a little smaller than that.&quot;</p>
<h2><strong>AI hasn't replaced him — but it has created a strange new client problem</strong></h2>
<p>Skiles doesn't use AI in his own work (&quot;I love the process of doing it myself&quot;), but it's already changed his client relationships in two opposite ways.</p>
<p><strong>The good:</strong> AI images are a genuinely useful communication tool. When a client arrives with a generated reference, &quot;it gives them a way to convey that to me, whereas it would've taken a lot longer to find what it is they're looking for.&quot;</p>
<p><strong>The bad:</strong> infinite generation creates choice paralysis. Clients show up with AI-generated options, he recommends a direction, and &quot;then they will go and generate 50 more options... at some point you have to make a choice so I can go in that direction with you.&quot; Worse is when a client falls in love with a specific AI image and just wants it reproduced: &quot;You're almost stuck trying to replicate the AI design for people, and you're like, 'What do you want me for then, if you just want me to redraw that thing for you?'&quot;</p>
<p>His overall stance is deliberate patience — the same wait-and-see approach he once took with Sketch before it won him over. Tools get easier to adopt over time, so there's little risk in watching first. The one area pulling him in: custom generators for textures and assets, where the designer keeps control over what's being generated.</p>
<h2><strong>He has thoughts about Liquid Glass</strong></h2>
<p>Skiles lived through the great flattening of iOS 7 — which, he says, made him better, because &quot;before that you had details and textures to hide behind... you just had to have a great shape.&quot; He loved the Big Sur era, where icons fit the squircle by convention but could still break out of it: &quot;an advised constraint versus a forced constraint, I think, gives the best results.&quot;</p>
<p>Tahoe's mandatory glass frame is another matter. Icon Composer saves him real time when a client wants a system-native look across platforms, but the non-negotiable edge highlight grates: &quot;If it was up to me, that effect wouldn't exist. Let people put glass within the icon, but just don't put it on the frame... But this is the world we live in. We accept what Apple gives us and we find a way to make it work.&quot;</p>
<p>In <a href="https://www.youtube.com/watch?v=4GWNH4o91Mw" target="_blank" rel="noopener noreferrer">the full episode</a>, Matthew also talks about the lost golden age of the Dribbble design community, why he almost never gets Android icon work, the current trend of custom glass shaders and mesh gradients, and the delicious-era Mac designers — Jasper Hauser's AppZapper ray gun among them — that he's still chasing: &quot;When I was a kid, that was my dream, and it's still my dream now.&quot;</p>
<h2>Guest links:</h2>
<ul>
<li><a href="https://matthewskiles.com/">Matthew Skiles's portfolio</a></li>
<li><a href="https://x.com/matthewskiles">Matthew Skiles on X</a></li>
<li>Matthew is also on Mastodon and Bluesky as @matthewskiles</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[A user costs $30 and returns $50. Yuliya Lennox says that’s bad.]]></title>
      <link>https://www.revenuecat.com/blog/growth/yuliya-lennox-solid-starts-sub-club-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/yuliya-lennox-solid-starts-sub-club-podcast-2026</guid>
      <pubDate>Wed, 22 Jul 2026 13:07:46 GMT</pubDate>
      <dc:creator><![CDATA[David Barnard]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Yuliya Lennox spent a decade scaling apps—and learned that a campaign making $20 per acquired user can be the clearest sign its creative is not good enough.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/89e692fc12ad727ffb875c9626f2e860c80e798c-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<h2><strong>A $20 margin can hide mediocre creative</strong></h2>
<p>When an app starts spending its first $100 or $500 a day on performance marketing, a stable return feels like success. A user costs $30 to acquire and returns $50. The campaign is profitable. Why not scale it?</p>
<p>Yuliya argues that this is exactly when a team should become suspicious.</p>
<p>“This is bad for you actually because you stop trying and you think, oh, my LTV ROI, whatever, it looks great. I'm doing everything fine. You are not doing fine. You are doing very, very badly.”</p>
<p><a href="https://www.youtube.com/watch?v=jzlPf100vy4">Watch on YouTube</a></p>
<p>A predictable margin can convince a team that it has solved acquisition before it has found a genuinely exceptional creative. The stronger signal is not steady performance across several ads. It is the outlier that changes the curve: CPA falls sharply, or purchases triple in a day.</p>
<p>That is the difference between an ad that backs out and an idea that can move the business. Early performance marketing should search for those step changes, not settle into comfortable averages.</p>
<h2><strong>BetterMe’s ugliest ad was the one David remembered</strong></h2>
<p>David Barnard still remembers a BetterMe ad he saw roughly a decade ago. It featured a deliberately unpleasant illustration of belly fat hanging over the front of someone’s body. It was not aspirational fitness advertising. It was visually jarring—and that is why it stayed in his memory.</p>
<p>Yuliya calls it “the best ad ever.” The polished images of slim people exercising disappeared into the category. The ugly illustration broke the pattern of the feed.</p>
<p>“They want something that catches them on the first glance.” — Yuliya Lennox</p>
<p>For an app selling a function, the first job of an ad is not to look like a campaign from an established consumer brand. It is to make the product and the pain it solves obvious in the first second. Brand polish is valuable only after the viewer understands what is being sold.</p>
<p>There is still a limit. At Solid Starts, a baby-feeding brand built around trusted expert advice, the team tested a creative featuring babies made out of fruit. The image attracted attention, but it also prompted parents to question whether they should trust the company feeding their children.</p>
<p>The lesson is not that every app should make every ad ugly. It is that most teams invoke “the brand” too early. Performance creative should have to prove that it damages trust before polish becomes the default constraint.</p>
<h2><strong>The No. 2 language app kept its founder in every marketing meeting</strong></h2>
<p>Founders often look for a senior marketer, agency, or CMO who can take growth off their plate. Yuliya’s experience suggests that delegation works better after the founder has developed their own feel for the market.</p>
<p>She points to Eva Learn English, which reached the No. 2 position behind Duolingo. Its founder attended every marketing meeting, developed creative concepts, and stayed close to how the product was communicated.</p>
<p>“It's not hard at all to understand marketing, but to feel it with your own hands, this is something that every founder needs to know.” — Yuliya Lennox</p>
<p>A founder does not need to remain the company’s permanent media buyer. But they do need enough firsthand experience to recognize a strong message, challenge a weak assumption, and know whether the team understands why customers buy. An agency can execute the work; it cannot manufacture that intuition on the founder’s behalf.</p>
<h2><strong>Sell the idea before you vibe code the app</strong></h2>
<p>Cheaper development has made it easier to build a complete product before proving anyone wants it. Yuliya recommends reversing that order.</p>
<p>“Just start with marketing, sell a PDF, sell an idea, sell something and then refund because you don't have a product.” — Yuliya Lennox</p>
<p>The tactic is intentionally uncomfortable. Put the promise in front of a real audience, ask people to pay, and learn whether demand exists before investing months in development. If the product does not exist yet, refund the buyers. The point is not to collect revenue; it is to replace encouragement from friends and family with evidence from the market.</p>
<p>This also forces a founder to define the business they are trying to build. A niche app might be perfectly capable of supporting a $20,000-a-month lifestyle business while remaining unsuitable for venture-scale economics. Marketing the idea early exposes that distinction before the cost base is locked in.</p>
<h2><strong>A $10 test market can save a $1,000 US experiment</strong></h2>
<p>Localization is usually treated as a revenue-expansion project. Yuliya also sees it as a way to make experimentation cheaper.</p>
<p>AI can translate a service in hours, making it practical to test more countries without a long localization project. Teams can use markets with lower CPMs and similar conversion behavior to learn which creative concepts deserve a more expensive US launch.</p>
<p>“You need to test 500 ads per week. And if you do that on US CPMs, you will definitely go broke.” — Yuliya Lennox</p>
<p>Yuliya says a comparable market such as Indonesia can sometimes turn a 1,000−a−day US test into a 10-a-day experiment, giving a team directional evidence before it pays US CPMs.</p>
<p>Cheap translation is not the same as local understanding. Replika made a serious push into Japan and even had a Japanese speaker on the team, but the localization still failed to gain traction. The practical play is to use international markets to widen the testing surface while recognizing that language alone cannot reproduce cultural context.</p>
<p>In <a href="https://www.youtube.com/watch?v=jzlPf100vy4">the full episode</a>, Yuliya and David also discuss how organic growth can become a trap, why subscription optimization can slide into black-hat behavior, and why running three apps at once prevents the war-room focus required to scale.</p>
<h2><strong>Guest links</strong></h2>
<ul>
<li><a href="https://www.linkedin.com/in/yulichkus">Yuliya Lennox on LinkedIn</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[A license to bill: Selling a Mac app without a merchant of record]]></title>
      <link>https://www.revenuecat.com/blog/engineering/license-mac-app-merchant-of-record</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/license-mac-app-merchant-of-record</guid>
      <pubDate>Mon, 20 Jul 2026 14:00:00 GMT</pubDate>
      <dc:creator><![CDATA[Jared Sorge, Dave DeLong]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[How Arborist sells outside the Mac App Store using RevenueCat and Stripe Managed Payments]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/635072d674724ef02bca1a2e23c1d16d6595a2a3-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>I hang out in a couple of online communities focused on app development. One of my favorites is a Slack community called &quot;AppKit Abusers.&quot; It's a great place for developers writing Mac apps to hang out, help each other work through problems, and share ideas. There's one question that gets asked regularly:</p>
<p>How do I make money?</p>
<p>This question comes up because many Mac developers choose to distribute their apps outside the Mac App Store. Sandboxing imposes restrictions on iOS apps, but it's even more extreme for Mac apps. Many of the things we've come to expect from &quot;<a href="https://daringfireball.net/linked/2020/03/20/mac-assed-mac-apps">Mac-assed Mac apps</a>&quot; over the years are simply not possible with sandboxing. However, disabling the sandbox on a Mac app comes at a cost: you cannot ship your app on Apple's App Store. This means that all of the work of collecting money from customers, handling licensing, etc. falls squarely on the developer's shoulders. And so every couple of months, like clockwork, another developer is in the Slack group asking for recommendations on how to implement licensing.</p>
<p>This is a problem I've faced several times myself. I love writing Mac apps; I've got three I'm building for myself right now. On iOS, I've used RevenueCat to simplify the logic around dealing with in-app purchases, and it's been wonderful to offload that mental burden onto a reliable framework, and allow me to focus on the task of <em>building my app</em>. I've wished something as easy existed for Mac apps, too.</p>
<p>Earlier this year, I joined RevenueCat as a Senior SDK Engineer and learned more about all the ways they help developers make money. Of course, I immediately thought &quot;Well, what about Mac developers?&quot; One feature in particular caught my eye: <a href="https://www.revenuecat.com/feature/web">web-based billing support</a>. This is a way for web developers (and mobile developers selling outside their platform stores) to offer subscriptions to their customers. Since all the information is associated with RevenueCat's service, purchases made on the web get tied to the customer’s profile, which can then be accessed via the RevenueCat SDK in an app. And since the RevenueCat SDK works on macOS… I quickly started connecting the dots and forming a plan.</p>
<p>After thinking about this approach, I put it on my to-do list to see if it would actually work. As luck would have it, a couple of days later my friend <a href="https://jsorge.net">Jared Sorge</a> mentioned he was looking to solve this exact problem, and he was game to try my idea. After some careful reading of the documentation, a couple of Zoom chats, and a bit of trial and error, Jared got it to work. In the end, it was extremely straightforward:</p>
<ul>
<li>Configure the product offerings and entitlements in the RevenueCat dashboard, like you would for any other app</li>
<li>Connect Stripe as a <a href="https://www.revenuecat.com/docs/projects/connect-a-store">web provider</a> and hook your offering up to a <a href="https://www.revenuecat.com/docs/web/web-billing/web-purchase-links">Web Purchase Link</a></li>
<li>Send identified users from your Mac app to the web to purchase</li>
<li>After purchase, ask the SDK to refresh, and see that they now have the purchased entitlement</li>
</ul>
<p>That's the short version. The longer version is Jared's story—how he got there for <a href="https://taphouse.io/arborist">Arborist</a>, his new Mac app launching today.</p>
<h2>Why not Paddle, FastSpring, or Lemon Squeezy?</h2>
<p>Arborist is a native macOS command center for git repositories and worktrees. It works by running user-entered commands at arbitrary locations, which means sandboxing—and therefore the Mac App Store—was never an option. Jared settled on a one-time version 1 purchase of $39, &quot;just like the Software Days of Yore,&quot; and then hit the question every direct-sales Mac developer hits: how do I do licensing on my own?</p>
<p>He knew the established options like <a href="https://www.paddle.com">Paddle</a>, <a href="https://fastspring.com">FastSpring</a>, and <a href="https://www.lemonsqueezy.com">Lemon Squeezy</a> have worked for many apps like his over the years. But:</p>
<p>I also wanted something simple for my users. Not to mention Apple Pay was a must-have. I've experienced friction more times than I can count when an app doesn't support Apple Pay, and every time it happens I am ever so slightly less happy with that app because of it. Customers are also more familiar with the smooth in-app purchase experiences provided by the App Store, and if I could achieve something that easy I wanted to do just that.</p>
<h2>No license server, no license keys</h2>
<p>What Jared landed on contains three pieces:</p>
<ul>
<li>RevenueCat serves as the source of truth for whether or not a customer is licensed.</li>
<li>Customer IDs come from local iCloud identifiers—specifically, calling <code>userRecordID()</code> on the app's CKContainer in CloudKit.</li>
<li>When the user makes a purchase, they earn an <a href="https://www.revenuecat.com/docs/getting-started/entitlements">entitlement</a> that Arborist looks for to grant them a license.</li>
</ul>
<p>The second bullet is the clever one:</p>
<p>I didn't want to have to spin up a licensing server to send out codes (much less have to store them), and I didn't want to have an email-based system. I wished for something simple like StoreKit but without using the App Store as a backend.</p>
<p>Because the customer ID is the user's iCloud record ID, a license bought on one Mac automatically follows the customer to every Mac signed in to the same iCloud account. There are no license keys to email, no &quot;Restore Purchases&quot; button, and no personally identifying information collected by the app. As Jared puts it: &quot;Stripe handles the payment process, and the iCloud's identifiers give nothing away.&quot;</p>
<p>The trade-off is that this strategy requires the customer to be signed in to iCloud—a reasonable assumption for a power-user app, and Jared is working on a web-based iCloud sign-in for everyone else, coming after 1.0.</p>
<h2>The merchant of record question</h2>
<p>Jared initially set up <a href="https://www.revenuecat.com/billing">RevenueCat Billing</a>, RevenueCat's own billing engine for web purchases, but then he ran into a term he'd never encountered:</p>
<p>I had never heard the term <a href="https://stripe.com/resources/more/merchant-of-record">merchant of record (MoR)</a> before and when I first did I didn't know it was something to care about (spoiler alert: it very much is).</p>
<p>The merchant of record is the entity legally responsible for the sale: collecting and remitting sales tax, VAT, and GST, and handling refunds. RevenueCat Billing does not act as your merchant of record: you are. For solo developers, this can add a significant amount of complexity. For Jared and his first direct-sale app, it was a dealbreaker.</p>
<p>The good news is that Stripe offers <a href="https://stripe.com/managed-payments">Managed Payments</a>, a merchant-of-record service where Stripe takes on tax collection and remittance per transaction, and RevenueCat <a href="https://www.revenuecat.com/docs/web/integrations/stripe/stripe-managed-payments">supports it out of the box</a>. From an app developer’s perspective, it’s the same entitlements, same SDK, and same Web Purchase Links; Stripe just carries the compliance burden. It adds a cost, but that was worth it to Jared:</p>
<p>Thankfully I found Managed Payments and from everything I can tell, making Stripe my MoR puts all the burden of these collections and distributions on Stripe. So I'm happy to give them a few extra percent of each sale to handle that for me.</p>
<h2>Setting up the backend</h2>
<p>Jared has documented his full setup, including the places he tripped up, <a href="https://jsorge.net/2026/07/15/revenuecat-mac-licensing">on his blog</a>. The condensed version is short:</p>
<ol>
<li>Create the entitlement in RevenueCat that the app checks for a license.</li>
<li>Add the product in the Stripe dashboard, making sure it meets Managed Payments' eligibility criteria.</li>
<li>Connect Stripe to RevenueCat as a <a href="https://www.revenuecat.com/docs/projects/connect-a-store">web provider</a>, checking the <a href="https://www.revenuecat.com/docs/web/integrations/stripe/stripe-managed-payments">&quot;Use Managed Payments when available&quot; box</a>.</li>
<li>Create the RevenueCat product by importing it from Stripe.</li>
<li>Add an offering containing the product with a <strong>lifetime</strong> duration—effectively a one-time purchase.</li>
<li>Generate a <a href="https://www.revenuecat.com/docs/web/web-billing/web-purchase-links">Web Purchase Link</a> for the offering, including the callback URL the web checkout uses to deep-link back into the app.</li>
</ol>
<h2>Hooking up the app</h2>
<p>The full purchase flow in Arborist looks very familiar to app users: open the License screen in the Settings, click “Buy,” complete the purchase in your browser, and automatically get returned to Arborist. That’s it.</p>
<p>Jared had initially hoped to keep everything in-app using a WKWebView, but a <a href="https://bugs.webkit.org/show_bug.cgi?id=282078">known WebKit bug</a> prevents Apple Pay from working in that context. Instead, he accepted the small friction of jumping out to the user’s browser so customers can use Apple Pay instead of typing in card details by hand.</p>
<p>Another caveat he found is that the RevenueCat SDK is geared toward apps that use StoreKit and doesn’t supply the corresponding web purchase link directly. Instead, he hard-codes the checkout URLs (production and sandbox) instead. When the user clicks buy, Jared's licensing view model assembles the checkout URL (shaped like <code>https://pay.rev.cat/{web_link_id}/{user_id}</code>) and hands it to the system browser:</p>
<pre><code class="language-swift">func startCheckout() async {
    guard isStartingCheckout == false else { return }

    isStartingCheckout = true
    actionMessage = nil
    defer { isStartingCheckout = false }

    do {
        let checkoutURL = try await licenseManager.checkoutURL()
        guard NSWorkspace.shared.open(checkoutURL) else {
            throw LicenseCheckoutOpenError.failedToOpenBrowser
        }
        actionMessage = &quot;Checkout opened in your browser.&quot;
    } catch {
        displayState = LicenseDisplayState.resolved(
            from: nil,
            previous: displayState,
            error: error
        )
    }
}</code></pre>
<p>After a successful purchase, RevenueCat's backend associates the passed-in user ID with the entitlement and calls back into the app via the registered deep link. The handler re-fetches the customer and validates the entitlement:</p>
<pre><code class="language-swift">func handleDeepLinkPurchase() async throws {
    let customerInfo = try await Purchases.shared.customerInfo(fetchPolicy: .fetchCurrent)

    guard
        let entitlement = customerInfo.entitlements[entitlementID],
        entitlement.isActive
    else {
        // Handle a missing entitlement; this means the
        // transaction did not succeed
        return
    }

    // Once we get here we have a validated customer who has
    // made a purchase and the app can be unlocked.
}</code></pre>
<p>That first line with the fetchPolicy is important:</p>
<p>The fetch policy here is critical because without specifying .fetchCurrent the SDK will return cached data, and if a customer has made a purchase that won't be reflected instantly like they'll expect. Instructing the SDK to bust out of its cached data is the thing that will make this process feel seamless to my customers (and yours!).</p>
<p>Similar code runs at app launch to validate the user against their iCloud ID, which is how a license bought on one Mac unlocks Arborist automatically on the next.</p>
<p>I was thrilled to discover that not only is using RevenueCat for desktop app licensing possible, but it's actually a straightforward setup process with a relatively small amount of in-app code. There are really only about three main areas where Jared needed to make changes: the UI for offering the purchase option to users and sending them to the web, the code to handle returning from the web, and the code that runs at startup to configure everything and check for existing purchases.</p>
<p>Having such a simple way to offer Mac app licensing to customers is <em>very</em> exciting to me. There are some ways I can see for the SDK to make this even easier, and I hope to start addressing them. If you're writing a Mac app and looking for a simple way to offer licensing to your customers, RevenueCat can help you make money.</p>
<p><a href="https://taphouse.io/arborist">Arborist launches today</a>—and Jared's full write-up, including everything he learned, is <a href="https://jsorge.net/2026/07/15/revenuecat-mac-licensing">on his blog</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[We partnered with Stripe so your agent can monetize your app]]></title>
      <link>https://www.revenuecat.com/blog/engineering/stripe-projects-cli</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/stripe-projects-cli</guid>
      <pubDate>Wed, 15 Jul 2026 17:21:52 GMT</pubDate>
      <dc:creator><![CDATA[Austin Blake]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[RevenueCat and Stripe Projects make app monetization setup as simple as one CLI command, so developers and coding agents can go from idea to payments faster.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/f48d6f4d81d73a6cb04927c999e8510595f5d5a3-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Your coding agent can now set up RevenueCat, provision a project, generate API keys, and get your app ready to accept payments. The whole thing takes just one CLI command.</p>
<p>stripe projects add revenuecat/app
</p>
<p>We built this with Stripe as part of <a href="https://projects.dev/">Stripe Projects</a>.</p>
<p><a href="https://www.youtube.com/watch?v=KYUzTMqETso">Watch on YouTube</a></p>
<h2><strong>Before this, monetization setup was still manual</strong></h2>
<p>Every other piece of your stack provisions in seconds. Database? One command. Auth? One command. Hosting? Done. But <em>until now</em>, monetization required you to stop and spend an afternoon creating accounts, reading StoreKit documentation, figuring out which shared secret goes where, generating API keys, configuring sandbox environments, and setting up server-to-server notifications.</p>
<p>It takes hours. Sometimes days. And you haven't written a single line of product code yet.</p>
<p>That gap between &quot;I have a working app&quot; and &quot;I can charge for it&quot; is absurdly wide for something that should be a solved problem.</p>
<p>So we fixed it.</p>
<h2>One command, working credentials</h2>
<p>If you're starting a new project today, here's what this looks like in practice.</p>
<p>You (or your agent) run one command. A few seconds later, you have a RevenueCat account, a configured project, and working API keys written directly to your <code>.env</code> file. You stay in the terminal the entire time.</p>
<p>$ stripe projects add revenuecat/app

✓ Created RevenueCat account
✓ Created project &quot;my-app&quot;
✓ Generated API keys
✓ Wrote REVENUECAT_APP_UUID to .env
✓ Wrote REVENUECAT_DASHBOARD_URL to .env
✓ Wrote REVENUECAT_SECRET_API_KEY to .env

Ready. Run `stripe projects open revenuecat` to view your dashboard.
</p>
<p>Products, entitlements, and offerings can be configured from the dashboard later, or your agent can handle them via our <a href="https://www.revenuecat.com/docs/tools/mcp">MCP server</a>.</p>
<h2>Agents provision it automatically</h2>
<p>If you build in Cursor, Claude Code, or Warp, the provisioning step is something your agent handles between writing your UI code and wiring up the SDK. You don't type the command. You just say &quot;add monetization.&quot;</p>
<p>You: &quot;I want to add a $9.99/month subscription to this app.&quot;

Agent: [runs stripe projects add revenuecat/app]
Agent: [reads .env, configures Purchases SDK]
Agent: &quot;Done. I've added the Purchases SDK to your project and
        configured it with your API key. Want me to create a
        product and build a paywall next?&quot;
</p>
<p>The agent doesn't need special RevenueCat knowledge. It picks up the Stripe Projects CLI and the skill files that <code>stripe projects init</code> already created. Monetization becomes a routine provisioning step, same as adding a database or setting up auth.</p>
<h2>Account, project, keys, full platform</h2>
<p>When you run <code>stripe projects add revenuecat/app</code>, the CLI provisions:</p>
<ul>
<li>A RevenueCat account (or links to your existing one)</li>
<li>A new project with sandbox and production environments</li>
<li>API keys synced to your local <code>.env</code></li>
<li>Access to the full platform: paywalls, entitlements, offerings, analytics, webhooks, and our AI Toolkit for ongoing management</li>
</ul>
<p>You'll still need Apple and Google developer accounts to go live on those stores. But RevenueCat itself (the account, the project, the API keys, the entitlement configuration) provisions from the terminal. And with RevenueCat's test store, you can test monetization on a real device without those store accounts.</p>
<p>From there, you integrate the <a href="https://www.revenuecat.com/docs/getting-started/installation">Purchases SDK</a> for iOS, Android, React Native, Flutter, KMP, or web. That's the path from &quot;I have an idea&quot; to &quot;I'm making money.&quot;</p>
<h2>Try it</h2>
<ol>
<li>Install the Stripe CLI with the Projects plugin:</li>
</ol>
<p>stripe plugin install projects
</p>
<ol>
<li>Initialize your project and add RevenueCat:</li>
</ol>
<p>stripe projects init my-app
stripe projects add revenuecat/app
</p>
<ol>
<li>Your <code>REVENUECAT_SECRET_API_KEY</code> is in <code>.env</code>. Integrate the SDK and start making money.</li>
</ol>
<p>For the full lifecycle, check our <a href="https://www.revenuecat.com/docs/getting-started/stripe-projects-quickstart">documentation</a> or ask your coding agent to handle product creation, paywall configuration, and entitlement management using the <a href="https://www.revenuecat.com/blog/company/ai-toolkit/">RevenueCat AI Toolkit</a>.</p>
<p>This is a developer preview. <a href="https://projects.dev/">Give it a try</a> and tell us what you think, especially if you're building with agents.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[A 19-day ChatGPT wrapper that made $10,000 in 36 hours]]></title>
      <link>https://www.revenuecat.com/blog/growth/joe-fabisevich-plinky-launched-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/joe-fabisevich-plinky-launched-podcast-2026</guid>
      <pubDate>Wed, 15 Jul 2026 12:52:48 GMT</pubDate>
      <dc:creator><![CDATA[Charlie Chapman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Joe Fabisevich left Twitter three days before the Elon Musk acquisition to build a link-saving app — but a scrappy side project taught him the true power of a single well-placed press email.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/7440b741dc0e9bbe6becdc1d7c5d41cd9e341a5d-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<h2>Treating a TestFlight like a production launch</h2>
<p>When Joe Fabisevich left Twitter to go indie, he didn't start by building his link-saving app, Plinky, in secret. Instead, he treated his TestFlight beta like a full production launch.</p>
<p><a href="https://www.youtube.com/watch?v=VNe6OcHlgnI">Watch on YouTube</a></p>
<p>For months, he ran a beta program that required real infrastructure. When he had to change Plinky's underlying data structure, he didn't just wipe the database—he built a complete export and import system so the 5% of users who were deeply attached to their links wouldn't lose them. &quot;It was not worth it in the sense of the value of the time that I spent on it,&quot; Joe admits. But the effort paid dividends. When the app officially launched and users inevitably asked for an export feature, the system was already built and battle-tested. Treating the beta with the care of a live product meant he already had the infrastructure—and the user trust—to scale.</p>
<h2>The $10,000 Daring Fireball email</h2>
<p>While building Plinky, Joe took a three-month detour. Six months after ChatGPT launched, OpenAI released the GPT-3.5 API. Joe and a friend saw an arbitrage opportunity: there was no official ChatGPT iOS app yet. They built Short Circuit, a barebones wrapper, in just 19 days.</p>
<p>The app's success wasn't driven by complex marketing or paid ads. It came down to one email. Joe pitched the app to John Gruber, framing it not just as another AI tool, but as a specific solution for Apple users. Gruber posted it on Daring Fireball. &quot;We started getting alerts from RevenueCat in our pocket,&quot; Joe remembers. &quot;We made like $5,000 in the first two hours.&quot; The app pulled in $10,000 in a day and a half. The lesson was immediate and clear: &quot;Definitely reach out to press, you idiot.&quot;</p>
<h2>Why freemium is a trap for indie apps</h2>
<p>When Plinky finally launched, Joe wanted to be generous. He offered a freemium tier that allowed users to save 50 links for free. His wife, a staff product marketer, warned him they would churn. She was right.</p>
<p>&quot;I went back and forth on freemium versus trial, and I settled on freemium because I want to be generous. And now I regret it,&quot; Joe says. The problem wasn't just lost revenue; it was lost data. Freemium users who churned disappeared invisibly, giving Joe fewer &quot;shots on goal&quot; to understand exactly when and why someone decided the app was worth paying for. He eventually dropped the free limit to 10 links and is now shifting toward a standard free trial model, realizing that charging users sooner is the only way to accurately measure the app's real value.</p>
<h2>The three-email formula for app sales</h2>
<p>Plinky is now priced at $39.99 a year, and Joe has found that the single best way to drive revenue is through structured email marketing to his free users. He collects emails upfront via Sign in with Apple, giving him a direct line to his audience that doesn't rely on App Store release notes.</p>
<p>His wife gave him a strict rule for sales: nobody cares if the discount is less than 50%. So he runs 50% off sales, and he never relies on just one email. The playbook is always three touches: an announcement email, a reminder a few days later to non-openers, and a final 24-hour warning. &quot;The last email usually drives the most people through the door,&quot; he explains. If the first email doesn't convert immediately, the strategy is working exactly as intended.</p>
<p>In the full episode, Joe also talks about how he organizes his launches using a &quot;lookback calendar,&quot; why he open-sourced his database library Boutique, and how he watched an AI agent completely rewrite the Short Circuit codebase in just three and a half hours.</p>
<h2>Guest links</h2>
<ul>
<li><a href="https://x.com/mergesort">Joe Fabisevich on X</a></li>
<li><a href="https://plinky.app">Plinky</a></li>
<li><a href="https://build.ms">Build.ms (AI Workshops)</a></li>
<li><a href="https://fabisevi.ch">Joe's Personal Site</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Almost everyone who pays for subscriptions is already on iOS 26]]></title>
      <link>https://www.revenuecat.com/blog/growth/minimum-ios-version</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/minimum-ios-version</guid>
      <pubDate>Tue, 14 Jul 2026 08:53:06 GMT</pubDate>
      <dc:creator><![CDATA[David Barnard]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[The business case for dropping support for earlier iOS versions
]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/f7eda0327571229e366bee6ceaf10c26f653de4e-1600x800.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Right before WWDC, Apple published its final adoption numbers for the iOS 26 cycle: <a href="https://www.macrumors.com/2026/06/09/ios-26-adoption-stats-wwdc/">79% of all iPhones, and 86% of iPhones introduced in the last four years, are running iOS 26</a>.</p>
<p>Those numbers convinced me to <a href="https://x.com/drbarnard/status/2064817113580363856">post a hunch on Twitter</a>: for most subscription apps, the share of paying users on iOS 26 is probably more like 95%. And if that’s anywhere close to true, it’s time to start thinking about iOS 26 as the minimum requirement. Adding new features faster could easily pay for the small drop in paying users.</p>
<p>App industry folks pushed back, added data, and sharpened the argument in ways I didn’t expect. So here’s the fuller case: when cutting older iOS versions makes business sense, when it doesn’t, and how to run the numbers on your own app.</p>
<h2>What the data says about who actually pays</h2>
<p>I made up the 95% number. Baran Toppare, a data scientist here at RevenueCat, came back with the receipts.</p>
<p>Across apps on RevenueCat, about 88% of paid subscribers who opened an app in the last 90 days are on iOS 26. iOS 18 still accounts for 9.4% of subscribers and 9.6% of revenue. New transactions tell the same story: 85% happen on iOS 26.</p>
<p>So, not 95%. But that platform-wide average includes massive apps like ChatGPT that pull it toward the broadest possible audience. Your app probably isn’t ChatGPT. When I checked Weather Up (my side-project weather app), roughly 94% of paying users active in the past 90 days were on iOS 26.</p>
<h2>The trade you’re actually making</h2>
<p>Raising your minimum OS costs you two things. Existing users on older versions keep the app, but frozen at the last compatible update. And new users on older versions can’t download it at all. That second one is the real cost, because it compounds: every new customer you can’t acquire is gone for good.</p>
<p>David Smith wrote <a href="https://www.david-smith.org/blog/2025/06/27/requiring-26/">the best conservative case</a> I’ve read. Nine months into the iOS 18 cycle, requiring the latest OS would have cut about 9% of Widgetsmith’s new downloads. As he put it: “If I could do something which boosted my downloads by 9% I’d be delighted”. His threshold: wait until older versions fall to about 1% of new downloads.</p>
<p>That’s a defensible line. Mine is more aggressive, and 2026 is a big part of why:</p>
<ul>
<li>Since April 28, <a href="https://developer.apple.com/news/?id=ueeok6yw">every App Store submission must be built with the iOS 26 SDK</a>. Your app already renders Liquid Glass for iOS 26 users, so supporting iOS 18 means shipping and QA-ing two design systems in one binary.</li>
<li>At WWDC26, Apple confirmed <a href="https://www.macrumors.com/2026/06/08/ios-27-supports-iphone-11-newer/">iOS 27 runs on the exact same devices as iOS 26</a> (iPhone 11 and newer). Requiring iOS 26 doesn’t strand anyone whose hardware could go further. Everyone excluded is on a 2018-or-older iPhone.</li>
<li>The cost shrinks every month. iOS 18 was down to <a href="https://telemetrydeck.com/survey/apple/iOS/majorSystemVersions/">about 10% of active devices by the end of June</a>, per TelemetryDeck. An iOS 26 minimum costs you a bit today, less in the fall, and almost nothing by next spring.</li>
</ul>
<h2>Why I required iOS 26 for Grit Method</h2>
<p>My latest side project app, <a href="https://gritmethod.app">Grit Method</a>, requires iOS 26. The UI leans heavily on Liquid Glass, and I didn’t want to check every animation and UI element against older simulators, then invent workarounds for a shrinking audience. One code path, the latest best practices, no backward-compatibility debt on day one.</p>
<p>There’s also a more practical problem: I no longer own a device running iOS 18. My whole family upgraded in the fall. Supporting an OS version you can only test in a simulator is its own quiet risk, especially when the people on it are paying you.</p>
<p>To be clear about my bias: I’m an indie moving fast, and the tradeoff is much smaller for me. Moving faster is worth more to my business than the single-digit percentage of paying users I’m giving up. The bigger your app, the more carefully you should run this math.</p>
<h2>When you shouldn’t cut older versions</h2>
<p>Some apps shouldn’t take this trade:</p>
<ul>
<li><strong>Apps where the audience skews toward older devices.</strong> Kids apps are the standout, since parents hand down old phones and buy used ones. Games with older demographics fit here too. As one reply on the thread put it, the Candy Crush crowd on iPhone 14s is still a market worth acquiring.</li>
<li><strong>Freemium apps that need reach.</strong> Free users skew toward older OS versions. If your model depends on a large free tier feeding a small paid one, cutting old versions taxes the top of your funnel hardest.</li>
<li><strong>Apps people need, not just want.</strong> If your app touches health, safety, or money, “they can buy a newer phone” isn’t an acceptable answer for the people who can’t.</li>
</ul>
<p>The biggest companies in the world land all over this spectrum, and the split tracks business model. <a href="https://faq.whatsapp.com/1150261202542208">WhatsApp still supports iOS 15.1</a>, a 2021 release, because reach is the product. Airbnb and Gmail already require iOS 18. Neither is wrong. They’re optimizing for different things.</p>
<p>One more nuance from the thread that stuck with me:</p>
<p>OS version is a proxy for device age, and device age is a proxy for purchase intent.</p>
<h2>The best counterargument</h2>
<p>Dan’s right that for some apps, maintaining backward compatibility is cheap. AI coding tools have made the #available dance easier than it used to be. If old-version support costs you almost nothing, keep it.</p>
<p>But “almost nothing” is doing a lot of work in that sentence. Two design systems isn’t nothing. An untestable device matrix isn’t nothing. And Dan’s own framing (better available and maybe buggy than not available) is a harder sell when the person hitting the bug is a paying subscriber.</p>
<h2>How to run your own numbers</h2>
<p>Don’t inherit last year’s minimum by default, and don’t inherit mine. Check four things:</p>
<p><strong>1. New users by OS version: </strong>In RevenueCat, go to the Customers tab and click on “New audience”. Add a filter for “First seen” with the last 90 days. Click the “Export all” button and then have your favorite AI tool analyze the data.For Weather Up, 83% of new users in the past 90 days were on iOS 26 or newer. </p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/231624bbd81364d3a30f183fbb77b74591ae8cbb-1776x816.png" alt=""/></figure>
<p><strong>2. New purchases by OS version:</strong> In RevenueCat, go to the Customers tab and select “New audience”. Add a filter for “First purchase date” with the last 90 days. Click the “Export all” button and then have your favorite AI tool analyze the data.For Weather Up, 85% of new purchases in the past 90 days were on iOS 26 or newer.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/176b174ad0036c427420e20f66b697072c40100f-1774x810.png" alt=""/></figure>
<p><strong>3. Active subscribers by last seen OS version: </strong>In RevenueCat, go to the Customers tab and select “Active subscribers”. Click the “Export all” button and then have your favorite AI tool analyze the data.For Weather Up, 94% of active subscribers are on iOS 26 or newer. Interestingly, 8.5% of active Weather Up subscribers are already on the iOS 27 beta.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/2a4a84533709c97b1669411c80c6d4f38f6a9b6b-1762x648.png" alt=""/></figure>
<p><strong>4. What the floor buys you.</strong> If requiring iOS 26 doesn’t change what you can ship or how fast, there’s no trade to make. For a Liquid Glass-heavy UI or anything built on iOS 26-only frameworks, the velocity gain is real.</p>
<p>So, did the data change my mind about Weather Up? No. But this is a judgment call: potentially losing 15% of new purchases is a real cost, and more than I expected when I tweeted.</p>
<p>Two things tip the decision anyway. We’re deep into a 4.0 update that fully embraces Liquid Glass, and dragging that redesign through a second, older design system is exactly the tax this whole post is about.</p>
<p>The second: the past 90 days of data is somewhat skewed by traffic source. Most of Weather Up’s current traffic comes from App Store search and being featured, which reach the broadest (and least bleeding-edge) audience. When the 4.0 update launches alongside iOS 27 in the fall, press and launch attention should skew new downloads back toward early adopters, closer to the 94% of active subscribers. Your new-payer mix isn’t a fixed property of your app; it follows your traffic.</p>
<p>So pick your line on purpose, and treat it as a line, not a law. David Smith’s is 1% of new downloads. Mine is “under 10% of new paying users, if the velocity gain is real”, and Weather Up just showed you both halves of that sentence doing work. What’s not defensible is picking a minimum on vibes (or this post) without ever looking at your own data.</p>
<aside class="tip"><strong>Keep reading</strong><p><a href="https://www.revenuecat.com/blog/engineering/wwdc26-whats-new-for-apps/">WWDC26: What’s new for subscription apps</a></p></aside>
<p>
</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[You could give a founder $20 million to hire engineers. Without product taste, they’d light it all on fire.]]></title>
      <link>https://www.revenuecat.com/blog/growth/andrew-maguire-volo-ventures-sub-club-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/andrew-maguire-volo-ventures-sub-club-podcast-2026</guid>
      <pubDate>Wed, 08 Jul 2026 13:11:40 GMT</pubDate>
      <dc:creator><![CDATA[David Barnard]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Andrew Maguire helped scale two App of the Year winners and now invests in consumer apps at Volo Ventures — and he's telling founders the best path to $10 million is to never raise a dollar of venture capital.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/94860accc61941efb56dd1df2e4f7c50e42087af-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<h2>The $20 million bonfire</h2>
<p>Five years ago, the path to building a consumer app was straightforward: raise venture capital, hire the best engineers, and build. Andrew Maguire thinks that playbook was always flawed — and now it’s irrelevant.</p>
<p>“You could give a founder $20 million to hire engineers and they can go hire all the best engineers,” he says. “And if they don’t have great product intuition and product taste and a process for building a great product, then they’re just going to light $20 million on fire.”</p>
<p><a href="https://www.youtube.com/watch?v=1R1_ZbmLyJI">Watch on YouTube</a></p>
<p>AI has collapsed the cost of building to near zero. RevenueCat co-founder Jacob Eiting agrees that the bottleneck has shifted: “The noise has gone up so much and there’s just so many options now that almost the only way to differentiate is by having something that stands out.” Product quality drives down acquisition costs, which drives efficient scale. Engineering talent is no longer the scarce resource. Taste is.</p>
<p>Maguire is particularly skeptical of the “one-shot app” approach — prompting an AI to generate a complete product from a few sentences. “Build me a great personalized fitness app and here’s a couple other sentences — because there’s just too many decisions to make.” The founders who’ll win are the ones using AI to iterate faster through those decisions, not to skip them.</p>
<h2>The clearest lane to $10 million has no VCs in it</h2>
<p>The conversation’s most striking claim came from Maguire mid-episode: “There has never been a better time to indie developer a $10 million app.”</p>
<p>The logic is simple. The old dynamic required venture capital to build anything meaningful — you needed engineers, infrastructure, and runway. VCs would then ask how you planned to disrupt Chess.com’s network effects, and if you couldn’t answer that, you couldn’t raise, and if you couldn’t raise, you couldn’t build. That entire chain has broken.</p>
<p>“Now there’s a very clear lane to build those products super cash efficiently,” Maguire says. Subscription infrastructure, revenue-based UA financing, and AI-assisted development mean a solo developer can reach eight figures without giving up equity.</p>
<p>Eiting reinforced this with a real example: Curtis Herbert of Slopes, who famously bootstrapped his ski tracking app to a team of roughly 12 people. “He’s built a really amazing business and he’s very happy with how it is. He did a very thoughtful and conscious way of choosing how to build that business.”</p>
<p>The practical implication: if you can’t clearly articulate how your company becomes worth a billion dollars, don’t raise venture. “Those investors get preferred stock,” Maguire explains. “If you sell your company for $20 million, they get all $20 million.” The math only works if you’re genuinely building toward a multi-billion dollar exit.</p>
<h2>What actually makes consumer investable in 2026</h2>
<p>For the apps that do warrant venture capital, Maguire has a simple filter: “You need to have a credible path to building a low churn product.”</p>
<p>That means one of two things. Network effects — hard to create, nearly impossible to disrupt once established. Or deep AI-powered personalization that creates usage lock-in, where the product becomes genuinely better the longer someone uses it.</p>
<p>“Think about a product that is going to be much better for the user in two years or three years than it is when they first started using it,” he says. “Not because of the promise of more features being shipped, but because of the usages.” Health, finances, nutrition — any domain where a full-time personal coach with access to 100% of your data would be immensely valuable.</p>
<p>The catch: these inference-heavy products are expensive to run. Maguire acknowledges the tension. “Once you taste the good model, I can’t go back. I want to use it for everything, which is really expensive.” His thesis is that this is precisely where venture capital makes sense — fund the gap between viral growth and the inevitable decline in inference costs. “Let the VCs bet on the fact that the cost of inference will come down.”</p>
<h2>Why apps aren’t going anywhere</h2>
<p>Near the end of the conversation, Maguire dismissed two “existential threats” that keep surfacing in industry discourse: that agents will replace apps, and that everyone will just build their own bespoke software.</p>
<p>“One of the classic mistakes in a venture-backed business is you get some company, they’re crushing it, they’re super confident, they’re growing really fast, and then the CEO’s like, ‘I don’t like Salesforce. We’re going to build our own CRM and it’s going to be better.’ And then it becomes a total boondoggle.”</p>
<p>The same logic applies to consumers building their own tools. It’ll happen at the margins — and it’ll compete on price with commercial software — but it won’t eliminate the market for dedicated products built by focused teams.</p>
<p>“People want beautiful visual experiences,” Maguire says. “It’s not only going to be agents talking to each other. People want to interface with something using their eyeballs.” The form factor might evolve, but the demand for taste-driven, purpose-built apps isn’t going away.</p>
<p>In <a href="https://www.youtube.com/watch?v=1R1_ZbmLyJI">the full episode</a>, Andrew, David, and Jacob also discuss the layered fee structures that erode LP returns in venture, why General Catalyst’s cohort-based UA financing model is interesting for mid-stage apps, and Andrew’s experiment building self-improving CRM tools with AI agents.</p>
<h2>Guest links</h2>
<p><a href="https://www.linkedin.com/in/andrewmaguire/">Andrew Maguire on LinkedIn</a></p>
<p><a href="https://x.com/AndyKMaguire">Andrew Maguire on X</a></p>
<p><a href="https://www.voloventures.com/">Volo Ventures</a></p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Recover churned subscribers automatically with win-back campaigns for web]]></title>
      <link>https://www.revenuecat.com/blog/company/win-back-campaigns-for-web</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/company/win-back-campaigns-for-web</guid>
      <pubDate>Tue, 07 Jul 2026 13:02:58 GMT</pubDate>
      <dc:creator><![CDATA[Niklas Winkels]]></dc:creator>
      <category><![CDATA[Company]]></category>
      <description><![CDATA[Win back subscribers who cancel, automatically. When someone's access expires, we send them an email with a discount offer. They resubscribe on the web, so you keep more of the money.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/086af4789af13c386461cee35b0677fc2e8c64d4-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Every app loses subscribers. And while both Apple and Google have native win-back offers, they take real work to set up: StoreKit 2 integration, App Store Connect configuration, eligibility rules, image assets, App Review. On Android, win-back offers can only be redeemed inside your app, so the user has to come back on their own.</p>
<p>What if you could reach churned subscribers via email, the moment their access expires? And what if the re-subscription happened on the web, where you keep more of the revenue?</p>
<p>That’s what win-back campaigns for web does. It’s now in public beta.</p>
<h2>2 settings, 60 seconds, done</h2>
<p>You configure a campaign in the RevenueCat dashboard in about 60 seconds. There are two settings: pick the app you want to monitor for subscription expirations, and choose a Web Purchase Link with your win-back offer. That’s it.</p>
<p>When a subscriber’s access expires (not when they cancel, but when they actually lose access), RevenueCat sends them a transactional email with a “Claim offer” button. That button takes them to your web checkout, where they re-subscribe at whatever discounted price you’ve configured. For example, a first year at $39.99 instead of $99.99 that auto-renews at the standard price.</p>
<p>The trigger fires only on genuine end-user churn. If you revoke access or cancel a subscription from your side, no email gets sent.</p>
<p>The email is branded to your app. It pulls in your app icon, name, and appearance settings automatically. The template includes a clear “Claim offer” call to action linking to your Web Purchase Link. You don’t need to design, write, or host anything. In the current beta, the template uses a standard layout and English copy. Email customization is on the roadmap.</p>
<h2>Reach users that native offers can’t</h2>
<p>Native win-back offers from Apple and Google are powerful, but they rely on the user returning to the App Store, opening your app, or checking their subscription settings. They’re passive. Your offer sits there and waits.</p>
<p>Win-back campaigns reach out at the exact moment access expires, via email, regardless of whether the user still has your app installed or what platform they originally subscribed on. A churned iOS subscriber, an Android subscriber, or a web subscriber all get the same email.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/9ce527b08a902510e2fc0222edee0c5ac64a209d-2272x1368.png" alt=""/></figure>
<p>And because the re-subscription happens on the web, you bypass the 15%–30% platform commission entirely. You can offer a generous discount and still improve your effective revenue per recovered subscriber. It’s money from users who would have otherwise left completely.</p>
<p>If you already have Apple or Google win-back offers configured, this works alongside them. More places for a churned user to resubscribe is a good thing.</p>
<h2>Send them anywhere: paywall or checkout</h2>
<p>The “Claim offer” link points to the Web Purchase Link you’ve configured. Depending on how you’ve set that up, the user can land on a paywall, a package selection screen, or go directly to checkout. You control the experience.</p>
<p>You can pair this with <a href="https://www.revenuecat.com/docs/web/web-billing/discounts">Flexible Discounts</a> to create the right offer: introductory pricing on a yearly plan, percentage-off codes, or whatever makes sense for your audience.</p>
<h2>Their account picks up where it left off</h2>
<p>The email link includes the user’s existing app ID. When they complete checkout on the web, their premium access is restored inside your mobile app immediately. The purchase shows up in their existing customer history, attributed to the same user, with the same entitlements.</p>
<h2>One campaign covers iOS, Android, and web</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/03ffa40312c960165e59c10c81f46414e48408d6-592x232.png" alt=""/></figure>
<p>Win-back campaigns work with all three of RevenueCat’s supported web billing engines: RevenueCat Billing, Stripe Billing, and Paddle Billing.</p>
<p>The app you monitor for expirations can be iOS, Android, or web. This means the feature supports both <strong>app-to-web</strong> (a mobile subscriber churns and re-subscribes on web) and <strong>web-to-web</strong> (a web subscriber churns and re-subscribes on web) flows.</p>
<h2>What you need before creating a campaign</h2>
<p>Win-back campaigns build on top of RevenueCat Web. Before you create one, you’ll need:</p>
<ul>
<li><strong>A web configuration.</strong> Connect a billing engine (RevenueCat Billing, Stripe Billing, or Paddle Billing) by following <a href="https://www.revenuecat.com/docs/web/overview">Getting started with RevenueCat Web</a>.</li>
<li><strong>A Web Purchase Link with your win-back offer.</strong> Create an offering with a discounted product (like a yearly plan with an introductory price), then create a Web Purchase Link for it. This is what the email links to.</li>
<li><strong>Your customers’ email addresses.</strong> RevenueCat sends the win-back email to the address stored in the <code>$email</code> <a href="https://www.revenuecat.com/docs/subscriber-attributes">subscriber attribute</a>. If your app already collects email during signup or onboarding, you’re set. If not, you’ll need to start setting it via the SDK.</li>
</ul>
<h2>Getting started</h2>
<p>Once those are in place, creating a campaign takes seconds:</p>
<ol>
<li>Go to <strong>Lifecycle → Win-back</strong> in your RevenueCat dashboard</li>
<li>Click <strong>Create campaign</strong></li>
<li>Select the app to monitor for subscription expirations</li>
<li>Select the Web Purchase Link containing your win-back offer</li>
<li>Click <strong>Create campaign</strong></li>
</ol>
<p>The campaign starts monitoring immediately.</p>
<p><a href="https://www.revenuecat.com/docs/web/winback-campaigns">Set up your first win-back campaign →</a></p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Announcing Shipaton 2026: Ship an app, win big, join the fun]]></title>
      <link>https://www.revenuecat.com/blog/company/announcing-shipaton-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/company/announcing-shipaton-2026</guid>
      <pubDate>Thu, 02 Jul 2026 09:49:18 GMT</pubDate>
      <dc:creator><![CDATA[Perttu Lähteenlahti, Charlie Chapman]]></dc:creator>
      <category><![CDATA[Company]]></category>
      <description><![CDATA[Shipaton 2026 — RevenueCat's global hackathon for mobile app builders, August 1 to September 30]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/e417ec04f4999f8c31deeb776436a0d2f12a8e60-2482x1160.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p><a href="https://www.youtube.com/watch?v=3yPFdwcSiok">Watch on YouTube</a></p>
<p>Shipaton is back. This <strong>August and September</strong>, we’re inviting builders from all corners of the globe to ship brand-new apps and compete for a share of <strong>over $1 million worth of prizes</strong>.</p>
<p>There’s a lot to cover, so here’s the TLDR:</p>
<ul>
<li><strong>Ship a new app</strong> in August or September, and you could win part of over $1 million worth of prizes</li>
<li><strong>New categories</strong>, including a brand-new student category</li>
<li><strong>ShipKit is back</strong> with tons of discounts and deals for participants</li>
<li><strong>Twice-a-week livestreams</strong> with expert guests and live Q&amp;A</li>
<li><strong>Shipaton IRL</strong> in-person events are back, all over the world</li>
<li><a href="https://shipaton.com/"><strong>Register right now</strong></a> at shipaton.com</li>
</ul>
<p>Alright, let’s dig into the details.</p>
<h2>The challenge</h2>
<p>Your challenge is simple:</p>
<ul>
<li><strong>Ship a brand-new iOS or Android app</strong> to the App Store, Google Play Store, or — new this year — the Samsung Galaxy Store, between <strong>August 1st and September 30th</strong></li>
<li>Use the <a href="https://www.revenuecat.com/docs/getting-started/installation">RevenueCat SDK</a> to power at least one in-app purchase, or serve ads through RevenueCat Ads</li>
</ul>
<p>That means a real app. In a real store. Released for the first time during the Shipaton window. Your app can be something you just started working on, or something you’ve been working on for a longer time but just haven’t shipped yet.</p>
<p>You can start brainstorming, building, posting, and getting people excited before August 1st. But to be eligible, your app has to be released no earlier than August 1st and no later than September 30th. We recommend shipping your 1.0 as quickly as possible to get through App Review, then pushing updates from there.</p>
<h2>Bigger than ever</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/a54a27adb620761ba70ebe09b99eac4c38720bd7-1415x630.png" alt=""/></figure>
<p><a href="https://www.revenuecat.com/blog/company/shipaton-2025/">Last year</a>, you completely blew us away. Close to a thousand new apps launched. Thousands of #BuildInPublic in TikTok and other social media. Communities all over the world hosted Shipaton IRL events. And winning teams ended up in New York, accepting their Shippy trophies on stage, with their apps lighting up a giant billboard in Times Square.</p>
<p>Naturally, we took that to mean that Shipaton 2026 needs to be even bigger. Here’s what’s on the line:</p>
<ul>
<li><strong>Over $700,000 in cash</strong> to date, and that number may grow as more sponsors join the fun</li>
<li><strong>Winning apps featured on giant billboards in Times Square</strong></li>
<li><strong>Flights to New York City</strong> for the Shippies red carpet award ceremony (that’s right, our own award ceremony for people who ship great apps) and RevenueCat’s exclusive <a href="https://appgrowthannual.com/">App Growth Annual</a> conference</li>
<li><strong>Press coverage</strong> for winners on 9to5Mac and 9to5Google</li>
<li><strong>Investor exposure</strong> through the brand-new Shipaton Growth Fund</li>
</ul>
<p>And there are even more prizes we’re still finalizing — we’ll announce those soon.</p>
<h2>New award categories</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/e5ca94f9e13dc9ce5f1d0b0c9685050726ae31ef-1401x600.png" alt=""/></figure>
<p>This year’s lineup of award categories celebrates all the different ways to build a great app. Many you’ll recognize from previous Shipatons, but there are some big new additions.</p>
<h3>Grand Prize: Build &amp; Grow Award</h3>
<p>The big one. This award honors the app that gains the most user traction and growth momentum during the event. We want to hear what you’ve done post-release to push your app’s growth to the next level.</p>
<h3>[NEW] Next Gen Award (students, this one’s for you)</h3>
<p>A brand-new category exclusively for students, designed to remove one of the biggest blockers for new builders: <strong>you don’t need a paid Apple or Google developer account to participate</strong>.</p>
<p>If you’re an active student, you can submit a video of your app plus your open-source code, and we’ll use that to validate your submission. High school, college, university, bootcamp, campus dev club, or another academic program: Shipaton is for you too. Learn more on the <a href="https://www.shipaton.com/next-gen">Next Gen page</a>.</p>
<p>And if you’re a student organizer, teacher, professor, or part of a developer club, this is the perfect excuse to bring Shipaton to your campus. More on that below.</p>
<h3>[NEW] Catvertising Award</h3>
<p>Celebrates the most creative and effective use of RevenueCat Ads as a monetization method. We’re looking for clever placements, smart integration with the rest of your revenue stack, and an experience users don’t hate.</p>
<h3>[NEW] Best Game Award</h3>
<p>Shipaton 2026 welcomes a whole new category of developers: This award goes to the best mobile game shipped during Shipaton. We’re looking for great gameplay, art direction, and a monetization fit that suits the genre.</p>
<h3>#BuildInPublic Award</h3>
<p>For developers who share the most interesting parts of their development journey on social media. We’re looking for compelling lessons learned or ideas incorporated from community feedback.</p>
<h3>HAMM Award (Help Apps Make Money)</h3>
<p>RevenueCat exists to help apps make more money. This award goes to the project with the most robust and creative monetization strategy.</p>
<h3>RevenueCat Design Award</h3>
<p>For the app that best represents the craft of app development, separate from its viability as a business. We’re looking for innovative ideas and/or beautiful design and animations.</p>
<h3>RevenueCat Peace Prize</h3>
<p>Awarded to the project that provides the greatest social good. We’re looking for apps with big benefits to communities or society at large.</p>
<p>On top of all that, our category sponsors will have dedicated award categories too. More details coming soon. Check the <a href="https://revenuecat-shipaton-2026.devpost.com/">DevPost page</a> for full judging criteria.</p>
<h2>Supported by the industry</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/1f621ead285bda775dba3928d9c2c7693aefde83-1406x513.png" alt=""/></figure>
<p>This year, our sponsors have shown up big. Huge thanks to <strong>Replit, OneSignal, JetBrains, Layers, Noise, Stripe, and Samsung</strong> for coming in as category sponsors for Shipaton 2026.</p>
<p>We’ll share more about their categories soon, but for now, just know they’re helping us make this year’s Shipaton bigger, weirder, and more rewarding for builders. And more sponsors are joining all the time — which means bigger cash prizes and more additions to the ShipKit.</p>
<h2>ShipKit is back</h2>
<p>ShipKit is your bundle of tools, credits, deals, and resources to help you build faster, launch better, and hopefully save a lot of money along the way.</p>
<p>Some of those deals are while supplies last, so the sooner you <a href="https://shipaton.com/">register on DevPost</a>, the sooner you get access as ShipKit starts to roll out.</p>
<h3>New this year: the #ShipatonSale</h3>
<p>If you have an app, tool, service, course, template, or anything else that could help Shipaton builders, you can run a sale for participants. Share it using the <strong>#ShipatonSale</strong> hashtag, and submit it to the Shipaton website, where we’ll list sales being run by tools and services in the community.</p>
<p>So even if you’re not competing, there are still ways to support the builders who are, and get your apps in front of thousands of people.</p>
<h2>Built on community</h2>
<p>Shipaton’s success has always come back to the community. It’s more fun when everyone can see each other building, learning, getting stuck, getting unstuck, and cheering each other on. Even with lots of prizes on the line, you’ve always supported and encouraged each other along the way, and we do our best to support that:</p>
<ul>
<li><strong>The #BuildInPublic award</strong> rewards sharing your building journey online — an amazing way to motivate yourself and inspire others</li>
<li><strong>The </strong><a href="https://discord.gg/shipaton"><strong>Shipaton Discord</strong></a> is open again for discussion, questions, feedback, finding teammates, and getting help from the RevenueCat team</li>
<li><strong>The #post-engagement-boost channel</strong> lets you share your #BuildInPublic posts and boost each other’s work so more people see what you’re building</li>
</ul>
<h3>Twice-a-week livestreams</h3>
<p>Throughout the event, we’ll host livestreams twice a week where you can learn new skills for building, launching, and growing mobile apps. Expert guests, practical sessions, and live Q&amp;A. So you’re not building alone for two months. You’ll have a steady stream of lessons and live support to keep you moving.</p>
<h2>Shipaton IRL: events near you</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/35dd495b7c4158253e42a6edeb27066364f992ed-1360x520.png" alt=""/></figure>
<p>The Shipaton community isn’t only online. Last year, builders and community organizers hosted Shipaton IRL events — meetups, co-working sessions, and launch parties — in cities all over the world. This year, we want even more of that.</p>
<p>Check out <a href="https://shipaton.com/events">shipaton.com/events</a> to find a Shipaton IRL event near you, or apply to host one yourself. Approved hosts get a Meetup-in-a-box with Shipaton swag, food vouchers, and organizer resources, and we’ll promote your event to the massive Shipaton audience.</p>
<p>These are perfect for new or existing developer communities, hacker clubs, and student organizers planning campus events.</p>
<h2>Ready, set, ship!</h2>
<p>Here’s what to do right now: go to <a href="https://shipaton.com/">shipaton.com</a>, click through to DevPost, and register. That’s where we’ll share the latest news, full rules, category updates, sponsor announcements, and ShipKit access as soon as it’s ready.</p>
<p>Shipaton is the perfect motivator to finally build that app idea you’ve had in your brain forever or to ship something totally new, now that app development (with a little help from AI tools) is more accessible than it’s ever been.</p>
<p>We can’t wait to see what you build. Let’s get shipping.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[A patron plan with no extra features outsold expectations — and proved design alone can be a business]]></title>
      <link>https://www.revenuecat.com/blog/growth/andy-allen-not-boring-software-launched-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/andy-allen-not-boring-software-launched-podcast-2026</guid>
      <pubDate>Wed, 01 Jul 2026 12:48:15 GMT</pubDate>
      <dc:creator><![CDATA[Charlie Chapman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Andy Allen built a 2-person app studio whose most expensive subscription tier unlocks essentially no extra features — and it outsells expectations every year.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/35f715d0f57b98df12e6fbbd91d6cef68a9c0dae-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<h2>The artist who burned his paintings at 40</h2>
<p>Not Boring Software didn’t start with a market opportunity or a gap analysis. It started with the conceptual artist John Baldessari.</p>
<p>Baldessari spent the first half of his life as a self-described “mediocre landscape painter.” Just before turning 40, he took his entire body of work — decades of paintings — and burned it all. Then he wrote “I will not make any more boring art” thousands of times on camera, in a kind of performance art ritual. He went on to become one of the most influential artists of his generation.</p>
<p><a href="https://www.youtube.com/watch?v=TCrwOUDifVM">Watch on YouTube</a></p>
<p>Andy Allen found Baldessari at almost exactly the same point in his own career. He was approaching 40, had spent the prior few years living in Amplitude dashboards at WeTransfer (which had acquired his previous company, FiftyThree), and felt himself slipping into the kind of B2B product work that was fashionable but soul-deadening.</p>
<p>“I felt like sometimes there’s just like good very simple heuristics,” Allen says. “If you can just say, ‘I will not make any more boring software,’ that might lead to some good outcomes.”</p>
<p>The timing was deliberately uncool. In 2020, nobody was starting an app business. Everyone was chasing SaaS. “It’s so funny because I just found that oftentimes the things that I’ve done that have had the most staying power or resonated the most have often been these very uncool things,” Allen says. “Whenever you try to do something cool or of the moment, it just doesn’t really go anywhere or it flubs.”</p>
<h2>The patron plan that shouldn’t have worked</h2>
<p>Not Boring launched with three apps — Weather, Calculator, and Timer — available individually or as a collection via subscription. For Weather, the subscription pitch was straightforward: premium data sources. For Calculator and Timer, what you were unlocking was… skins. Different visual themes. Alternative app icons.</p>
<p>Conventional wisdom says this shouldn’t work. People don’t pay for aesthetics in utility software. But Allen and his co-founder added something else: a “patron plan” priced at five times the standard tier. It included essentially nothing extra — a physical gift, and the knowledge that you were funding the studio’s work.</p>
<p>“It was shocking how many people went for that plan,” Allen says. “And that was kind of the moment that we realized, oh yeah, this is a little bit of a different business model. We’re not selling features here. People want to see this work and see us maybe as the knights fighting this battle against boring software and they want to fund this effort.”</p>
<p>The model has compounded over five years. Early subscribers got their price locked in permanently. Every new app (one per year) is automatically included. The incentive structure pushes Allen toward making new, interesting things rather than grinding on feature parity with competitors. “We’re not trying to chase features that are going to unlock some new audience for us or that we think are going to suddenly bump up our conversion rates,” he says.</p>
<h2>Why every Not Boring app ships as a “complete” product</h2>
<p>Allen’s previous company, FiftyThree, made Paper — the drawing app that became one of the first native-feeling iPad apps and eventually sold to WeTransfer. That experience taught him what happens when you raise VC money for a product that’s a great small business but not a billion-dollar outcome: you get trapped.</p>
<p>“The biggest fear was getting trapped in a business that you didn’t want to run,” Allen says. “And I see that happen with so many founders.”</p>
<p>The structural solution at Not Boring is radical simplicity. Each app ships as a complete product. No V3. No feature bloat. No chatbot in the corner. “I prefer to say like, ‘This is the calculator that we made.’ It’s complete and it does its job really well. We’re not going to change it on you.”</p>
<p>This creates a forcing function: if you can’t go back and iterate on old apps, you have to make new ones. And making new things — re-envisioning old software categories through a design-first lens — is the work Allen actually wants to do. The constraint is the point.</p>
<h2>A dozen sound files for one button</h2>
<p>The Not Boring apps feel like video games. Everything runs in Apple’s 3D engine (SceneKit), with low-poly models inspired more by the PS1 era than photorealism. But the detail that most people notice first is the sound.</p>
<p>Allen plays sounds even when the iPhone’s mute switch is on — something that would get most apps destroyed in reviews. It works here because the sound is the experience. But the real trick is subtler: every button tap doesn’t play one sound file. It plays one of a dozen slightly different versions of the same click.</p>
<p>“No button ever sounds exactly the same,” Allen explains. “Why does it sound so great to listen to someone typing on an old clickety-clackety keyboard? It’s because of all the variation. When you hit a physical button, it hits differently every time.”</p>
<p>This is a technique borrowed directly from game audio, where footsteps and punches need to repeat without becoming grating. Allen published the technique — along with free sound kits — hoping other developers would adopt it. “It doesn’t take much, honestly. It just takes working with a sound designer,” he says. “It’s a great way to amplify your experience that really doesn’t add much overhead or complexity to the app itself.”</p>
<h2>3 years to build a camera that launches faster than the competition</h2>
<p>Not Boring Camera took three years of false starts before shipping. The team explored 3D photography, spatial photos, and various experimental directions before arriving at a simpler insight: don’t reinvent what a camera does — reinvent what it feels like to use one.</p>
<p>Every control in the camera app is modeled in 3D. Dials turn. Sliders have physical weight. Buttons tilt based on where you press them. The preview isn’t full-screen — it’s inset, like looking through a viewfinder. And despite running in a game engine, the app launches faster than every third-party camera app Allen has tested.</p>
<p>But the real philosophical bet is on the image pipeline. Not Boring Camera strips out all of Apple’s computational photography — the multi-frame composites, noise reduction, and HDR tone mapping — and works directly with raw sensor data. Allen calls it “Super Raw” (a nod to Apple’s “ProRAW,” which he argues is still heavily processed).</p>
<p>“The default camera apps — it’s not just Apple, it’s Google, it’s everyone — their goal is to make sure that you never take a bad photo,” Allen says. “But I think the result is that you never can really take a great one.”</p>
<p>The app applies film-inspired LUTs in real time, lets you edit raw exposure and temperature before shooting, and embraces grain and blur as creative choices rather than flaws. “Some of my favorite photos from our app are accidents,” Allen says. “It’s like I accidentally hit the shutter button as I was putting it away so it’s like this really cool blur. I miss some of that and I think we’re trying to bring some of that back.”</p>
<p>In <a href="https://www.youtube.com/watch?v=TCrwOUDifVM">the full episode</a>, Andy also talks about designing the never-shipped Microsoft Courier tablet, why the early Macintosh shaped his view of computers as creative tools, and his recommendations for checking out the artist John Baldessari and musician Brian Eno as models for creative thinking.</p>
<h2>Guest links</h2>
<p><a href="https://x.com/asallen">Andy Allen on X/Twitter</a></p>
<p><a href="https://notbor.ing/">Not Boring Software</a></p>
<p><a href="https://apps.apple.com/app/not-boring-camera/id6504163047">Not Boring Camera on the App Store</a></p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Your app is perfectly optimized. That’s why nobody remembers it.]]></title>
      <link>https://www.revenuecat.com/blog/growth/optimized-app-forgettable</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/optimized-app-forgettable</guid>
      <pubDate>Tue, 23 Jun 2026 09:43:08 GMT</pubDate>
      <dc:creator><![CDATA[Olha Yohansen]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[When every app copies the same playbook, the only advantage left is meaning something.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/95b7b046519d94aefad6635dbdfd16fd4753c9a0-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>There is a moment most of us have had. You open a habit tracker, a period tracker, a sleep app, and for a second you are not quite sure if it is the one you downloaded last week or the one you have been using for months. The icon looked familiar. The onboarding felt familiar. The paywall was… definitely familiar.</p>
<p>This is not a coincidence, and it is not anyone’s fault.</p>
<p>For as long as apps have been a business, the advice has been consistent: look at what is already working. Find the apps that are converting, understand why, get inspired by the patterns that have been proven. This is genuinely good advice. It is how most successful products got to where they are. Learning from what works is faster, cheaper, and less risky than starting entirely from scratch. I have followed this advice myself, and I have given it to others.</p>
<p>But something happens when an entire industry follows the same advice all the time. The patterns converge. The quiz-style onboarding. The projection graph that dramatically reveals your results, then changes four times as you progress through the funnel promising you that dream weight goal achieved by your next holiday. The interrupting pop-ups on pre-paywall loaders. The three-tier paywall with the annual plan highlighted and the countdown timer. The CTA copy that has been A/B tested into the least interesting version of itself. These things work, until everyone is doing them, and then they stop being an advantage. They become the baseline.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/52dc06bfcf7b5c45d5e171c70b2e2db9fab6ed8c-1603x1704.png" alt=""/><figcaption>Same heading, same diagram, same labels, same body copy. Flo Health (left) and Clover (right).</figcaption></figure>
<p>This is what “get inspired by competitors” looks like when the ‘iterate’ step gets skipped. Getting inspired is fine. Leaving it there is not. When users compare apps, they notice. Please have some <s>ethics</s> heart.</p>
<p>And this pattern goes deeper than a single screen. Look at what happened with the personalized weight-loss graph. Noom built it: a projected weight loss curve tied to a personal date, a specific goal, even a life occasion. “And reach your goal by the vacation.” It was a genuinely good idea that worked.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/bac5e9b7e54e128f3e0eb4395d1690db27f7e7f4-1601x820.png" alt=""/><figcaption>Noom’s personalised weight-loss projection (far left) spawned near-identical versions across the category. The rightmost example added milestone markers and social proof – same starting point, genuinely different idea.</figcaption></figure>
<p>The copies came quickly. Almost everyone who tested it saw an uplift. The headline – “the last plan you’ll ever need” – spread across the category until almost every app in weight loss was apparently offering the definitive final solution to the human condition millions have still not solved. Most of the copies stopped there.</p>
<p>But one app went further. It kept the projection concept, then added intermediate weight milestones along the curve so the user could see the journey in stages, not just the destination. They did it because they knew their product well, they knew the result looked like ‘steps’ with short ‘walls’ – their users told them that in these same words. Then they placed social proof from those real users directly below the graph, at the exact moment the user might feel sceptical about whether the prediction is real. That is iteration. Same starting point, genuinely different idea, serving the user better at a specific moment of doubt. This is what I call ‘heart’.</p>
<p>I have worked with a number of B2C apps across health, wellness, and productivity. I have seen a lot of funnels from the inside. And what I notice more and more is that the challenge has quietly shifted. Building a well-structured app, with a clean onboarding flow and a tested paywall, is no longer what separates the apps that grow from the ones that do not. There are companies out there who have onboarding funnel constructors with proven screens, sections, even CTAs. The framework is defined – nothing to invent there.</p>
<h2><strong>The real question now</strong></h2>
<p>The harder question now is: once you have paid to get someone’s attention, what makes them remember you, choose you and stay with you?</p>
<p>Building has never been easier. Getting memorable has never been harder. That gap is where the real work happens now.</p>
<p>I want to be upfront about something before we go further. This is not a case against learning from competitors. I have spent years advising apps to do exactly that, across more than twenty products I have worked with as a consultant in the past few years. Look at what is working, understand why, adapt it for your context, move fast. I still give that advice. It is the right starting point.</p>
<p>But there is a step that routinely gets skipped. The “iterate quickly” part, the part where you take what you have learned and make it genuinely yours, tends to get dropped in favour of just shipping. And I understand why. You need momentum. You need to see if the model works before you invest in making it feel like something. That is reasonable.</p>
<p>The problem is that “making it yours” never comes after, because there is always another thing to ship. And so the app stays at the “inspired by competitors” stage indefinitely.</p>
<p>What I am arguing here is that making it yours is not a polishing step – it is the thing that determines whether users stay. And it has two sides, read on.</p>
<p><strong>The first is internal</strong>: it has to come from what you actually believe about the problem you are solving. Why did you build this product? What did you see that others were missing? What do you genuinely think users need? That conviction, when it comes through in the product, is what makes an app feel like it was made with care rather than assembled from a template. It’s usually the gut feeling you already have but might be too worried about testing – you have my permission to go ahead and test it out – those things usually work in a surprising way.</p>
<p><strong>The second is external</strong>: it has to speak to the user in their own words. Not your words about them. Their words about themselves. User research, interviews, even reading App Store reviews in your category give you the vocabulary your users actually use to describe their own problems. When that vocabulary shows up in your onboarding, in your push notifications, in your emails, users feel understood in a way they cannot quite name but definitely feel.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/628a015571c89ab2613071b90ed65ca68c9f9b96-1601x1153.png" alt=""/><figcaption>Three apps that speak the user’s language from the first screen: Flo confirms you’re in the right place with social proof, How We Feel asks goals in the user’s own framing, and Fabulous turns onboarding into a personal commitment.</figcaption></figure>
<p>The practical starting point is user research: interviews, App Store reviews in your category, even the language people use on Reddit threads about their problem, tweets [see above]. That vocabulary, the words real users use to describe their own situation, is what your onboarding questions should be built from. When it comes from genuine understanding of the user rather than internal product language, users feel it. They cannot always name why an onboarding feels right. but they vote with their wallet.</p>
<p>That combination, believing in what you are building and speaking the user’s language, is what I mean by heart throughout this article. It is specific, and it is learnable.</p>
<h2>The shift we’re entering: from careful to alive</h2>
<p>Building an app used to be slow and expensive. You validated carefully, prioritized ruthlessly, shipped incrementally. RICE scores ruled the roadmap. Anything that was hard to measure (delight, tone of voice, a moment of surprise) got cut. Did not score well enough. None of the competitors have that screen. Next.</p>
<p>AI-assisted development, simplified releases you control on the back-end and tools that have recently emerged have dramatically reduced the cost of building and iterating. No-code tools are genuinely capable. A solo founder or an IC Growth operator can ship new concepts in days. This is exciting.</p>
<p>Here is the catch: distribution is harder than it has ever been.</p>
<p>The number of new subscription apps launching monthly has exploded: from roughly 2,000 per month in early 2022 to over 14,700 by early 2026, according to RevenueCat’s <a href="https://www.revenuecat.com/state-of-subscription-apps/">2026 State of Subscription Apps report</a>. App Store search is more competitive. User acquisition costs always keep climbing. Organic growth is rare and unreliable.</p>
<p>And many of these apps, built from the same templates and UX patterns AI picked up from apps that copied each other, end up feeling identical. AI picked the design. AI picks it for everyone. [funny story – when writing this article I gave Claude the two discharge screenshots from earlier and it immediately identified ‘the original’ and cited a pattern of what got copied and why].</p>
<h2>The missing piece: heart alone doesn’t scale</h2>
<p>There is a tempting counter-move, and I have seen it play out. The response to “everything feels the same” is to swing the other direction entirely: build from the heart. Make it personal. Build the app you would want to use. Obsess over the details.</p>
<p>This is not wrong. I am a firm believer in it, and genuinely happy we are living in a moment where building from a real place is possible again, without the six-month approval process killing the idea before it ships.</p>
<p>But heart alone does not get you anywhere if no one finds you. And if the people who do find you cannot figure out how to subscribe.</p>
<p>The apps that win in this environment need three things working at the same time:</p>
<ul>
<li><strong>Something that resonates</strong>: a specific user, a real problem, messaging that lands</li>
<li><strong>Distribution</strong>: a well-tuned paid acquisition engine, a product loop that earns organic growth, or both</li>
<li><strong>A system to convert and monetize</strong>: an onboarding flow, a paywall, a pricing strategy that translates attention into revenue</li>
</ul>
<p>Pick one and you are halfway there. Pick all three and you are dangerous.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Understanding Google Play’s Billing Choice: a complete guide to offering alternative billing]]></title>
      <link>https://www.revenuecat.com/blog/engineering/play-billing-choice</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/play-billing-choice</guid>
      <pubDate>Tue, 23 Jun 2026 02:28:37 GMT</pubDate>
      <dc:creator><![CDATA[Jaewoong Eum]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[Billing Choice comes down to two decisions — who renders the choice screen and where payment happens — over one shared setup and one mandatory sale report.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/99b1fdf103a5621562ad14061d2650fdc368894a-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>For most of Google Play’s history, charging for digital goods inside an Android app meant one path. You integrated Google Play Billing, Google processed the payment, and Google collected its service fee. <a href="https://developer.android.com/google/play/billing/billingchoice/integration">Billing Choice</a> changes the shape of that flow. It lets your app present a screen where the user picks between Google Play Billing and your own billing system, or a link out to your website, and then completes the purchase through whichever option they chose. The idea is simple to state, but the integration carries real weight: you decide who draws the choice screen and where the money is collected, and you take on an obligation to report every alternative sale back to Google.</p>
<p>In this article, you’ll explore what Billing Choice changes about the purchase flow, the four integration scenarios that fall out of two independent decisions, the <code>BillingProgram</code> API surface for enabling the program and checking its availability, how each scenario launches its purchase, the external transaction token that ties every alternative sale to a server side report, how subscription upgrades and downgrades behave, and the UX rules that keep the choice screen fair.</p>
<h2><strong>What Billing Choice actually changes</strong></h2>
<p>Google describes the program plainly: the billing choice program lets you integrate your own billing system or guide users to your website for purchases using external web links. Whichever option you implement, the user must still be offered a choice between Google Play Billing and your alternative.</p>
<p>That last clause is the heart of it. Billing Choice is not “alternative billing instead of Google Play.” It is “alternative billing alongside Google Play, with the user deciding.” Google Play Billing never disappears from the screen. Your job is to present a fair choice and then honor whichever side the user taps.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/1d86b5831466bd6c7c27f540b768e9403cf5b05b-1265x1999.png" alt=""/></figure>
<p>The program is exposed through a <code>BillingProgram</code> enum, and the value you work with is <code>BillingProgram.BILLING_CHOICE</code>. You pass that value into nearly every Billing Choice call, which signals the shape of the API: alternative billing is configured as a named program you opt into, not a single global switch.</p>
<p>Two independent decisions define how your integration looks:</p>
<ul>
<li><strong>Who renders the choice screen</strong>: Google can draw it for you (<code>ChoiceScreenType.GOOGLE_RENDERED</code>), or you can draw your own (<code>ChoiceScreenType.DEVELOPER_RENDERED</code>) as long as you follow the UX guidelines.</li>
<li><strong>Where the payment happens</strong>: the user pays inside your app through your own billing system (<code>DeveloperBillingType.IN_APP</code>), or you send them to your website through an external web link (<code>DeveloperBillingType.EXTERNAL_LINK</code>).</li>
</ul>
<p>The <code>DeveloperBillingType</code> matters beyond the user experience. It is recorded inside the transaction token you generate, so Google knows whether a given alternative sale was an in app transaction or an external link transaction when it assesses the service fee.</p>
<h2><strong>The four integration scenarios</strong></h2>
<p>Those two decisions combine into the four scenarios that organize the entire integration. Everything else in this guide is a variation on one of these four rows.</p>
<table>
<thead><tr>
<th><p>Scenario</p></th>
<th><p>Choice screen</p></th>
<th><p>Payment location</p></th>
<th><p>How the purchase launches</p></th>
</tr></thead>
<tbody>
<tr>
<td><p><strong>1A</strong></p></td>
<td><p>Google renders it (<code>GOOGLE_RENDERED</code>)</p></td>
<td><p>In app (<code>IN_APP</code>)</p></td>
<td><p><code>launchBillingFlow()</code> with a developer billing option enabled</p></td>
</tr>
<tr>
<td><p><strong>1B</strong></p></td>
<td><p>You render it (<code>DEVELOPER_RENDERED</code>)</p></td>
<td><p>In app (<code>IN_APP</code>)</p></td>
<td><p>You handle the tap yourself, then process payment and report</p></td>
</tr>
<tr>
<td><p><strong>2A</strong></p></td>
<td><p>Google renders it (<code>GOOGLE_RENDERED</code>)</p></td>
<td><p>External link (<code>EXTERNAL_LINK</code>)</p></td>
<td><p><code>launchBillingFlow()</code> with a link URI and token attached</p></td>
</tr>
<tr>
<td><p><strong>2B</strong></p></td>
<td><p>You render it (<code>DEVELOPER_RENDERED</code>)</p></td>
<td><p>External link (<code>EXTERNAL_LINK</code>)</p></td>
<td><p><code>launchExternalLink()</code> with a link URI and token attached</p></td>
</tr>
</tbody>
</table>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/fbc90b11a288eb6e26696728633d79de2d88e825-2048x1085.png" alt=""/></figure>
<p>Notice the pattern. The “1” versus “2” axis is about where money is collected. The “A” versus “B” axis is about who renders the screen. When Google renders the choice screen (the A scenarios), Google needs a way to tell you the user picked your option, so you register a listener up front. When you render the choice screen (the B scenarios), you already know what the user tapped, so instead of a listener you fetch the assets you need to draw a fair screen and generate the reporting token yourself.</p>
<h2><strong>Prerequisites: library version, enrollment, and Play Console</strong></h2>
<p>Before any code compiles against this API, three things need to be in place.</p>
<p>First, the library. Billing Choice requires Play Billing Library version 9.1 or higher. This is separate from the broader migration deadline you may already be tracking: by August 31, 2026, every new app and every update to an existing app must build against Billing Library 8 or later, with an extension available on request until November 1, 2026. That deadline is about version 8 as a floor for the whole ecosystem. Billing Choice sits well above it at 9.1, so adopting the program puts you well past that floor.</p>
<p>Second, enrollment. You must enroll in the program and review its requirements before calling the APIs. If you intend to offer external web links, you also declare that preference in Play Console before you ship the integration.</p>
<p>Third, Play Console configuration. You set your choice screen preference (whether Google renders it or you do) and your external web links preference in the Console. You can also upload an image asset that represents the payment methods your own billing option accepts, to be shown on the choice screen. That asset follows strict specifications:</p>
<table>
<thead><tr>
<th><p>Asset specification</p></th>
<th><p>Value</p></th>
</tr></thead>
<tbody>
<tr>
<td><p>Overall image size</p></td>
<td><p>192dp x 20dp</p></td>
</tr>
<tr>
<td><p>Single card size</p></td>
<td><p>32dp x 20dp</p></td>
</tr>
<tr>
<td><p>Spacing between cards</p></td>
<td><p>8dp</p></td>
</tr>
<tr>
<td><p>Inner padding</p></td>
<td><p>3dp</p></td>
</tr>
<tr>
<td><p>Card outline</p></td>
<td><p>1dp inner stroke, 2dp radius, color <code>#E0E0E0</code></p></td>
</tr>
<tr>
<td><p>Card background</p></td>
<td><p>Solid color, preferably white</p></td>
</tr>
<tr>
<td><p>File format</p></td>
<td><p>PNG with transparent background</p></td>
</tr>
<tr>
<td><p>Maximum payment methods</p></td>
<td><p>5</p></td>
</tr>
</tbody>
</table>
<h2><strong>Enabling the billing program</strong></h2>
<p>You turn on Billing Choice when you build the <code>BillingClient</code>. The new piece is <code>enableBillingProgram()</code>, which takes an <code>EnableBillingProgramParams</code> configured with <code>BillingProgram.BILLING_CHOICE</code>.</p>
<p>The following setup is for a scenario where Google renders the choice screen:</p>
<pre><code class="language-kotlin">val params = EnableBillingProgramParams.newBuilder()
    .setBillingProgram(BillingProgram.BILLING_CHOICE)
    .setDeveloperProvidedBillingListener(developerProvidedBillingListener)
    .build()

val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases(pendingPurchasesParams)
    .enableBillingProgram(params)
    .build()</code></pre>
<p>The important detail is <code>setDeveloperProvidedBillingListener()</code>. You register this listener only when Google renders the choice screen, because Google needs a callback to reach when the user picks your billing option rather than Play’s. The <code>purchasesUpdatedListener</code> you already know that standard Billing handles the case where the user picks Google Play. The two listeners sit in different places, the familiar <code>PurchasesUpdatedListener</code> on the client and the <code>DeveloperProvidedBillingListener</code> inside <code>EnableBillingProgramParams</code>, but together they cover the two outcomes of the choice screen.</p>
<p>When you render your own choice screen, you omit the <code>DeveloperProvidedBillingListener</code> entirely. You build the same <code>EnableBillingProgramParams</code> with <code>BillingProgram.BILLING_CHOICE</code>, but without <code>setDeveloperProvidedBillingListener()</code>, because you handle the user’s tap yourself and never need Google to call back into your app.</p>
<h2><strong>Checking availability and reading the choice configuration</strong></h2>
<p>Billing Choice is not guaranteed to be available for a given user, device, or region, and the configuration you set in Play Console is delivered to your app at runtime. You check both with <code>isBillingProgramAvailable()</code>. The Kotlin form is a suspend function that returns the result alongside an availability details object:</p>
<pre><code class="language-kotlin">val (billingResult, availabilityDetails) =
    billingClient.isBillingProgramAvailable(BillingProgram.BILLING_CHOICE)

val choiceDetails = availabilityDetails.billingChoiceAvailabilityDetails
if (billingResult.responseCode == BillingResponseCode.OK &amp;&amp; choiceDetails != null) {
    val screenType = choiceDetails.choiceScreenType
    val externalLinkSupported = choiceDetails.isExternalLinkAvailable
    \/\/ Branch your integration on these two values.
}</code></pre>
<p>The <code>BillingChoiceAvailabilityDetails</code> object answers the two questions that decide which scenario you are in. The <code>choiceScreenType</code> property is either <code>ChoiceScreenType.GOOGLE_RENDERED</code> or <code>ChoiceScreenType.DEVELOPER_RENDERED</code>, telling you who is responsible for rendering the screen. The <code>isExternalLinkAvailable</code> property tells you whether external web links are enabled for this user. A robust integration reads both values and falls back to standard Google Play Billing whenever Billing Choice is unavailable, rather than assuming the program is always on.</p>
<p>For callback-style code, there is also <code>isBillingProgramAvailableAsync()</code>, which delivers the same <code>BillingResult</code> and availability details to a listener instead of suspending.</p>
<h2><strong>Scenario 1A: Google renders the choice, payment happens in app</strong></h2>
<p>This is the lightest scenario to integrate, because Google does most of the work. You enable the program with a <code>DeveloperProvidedBillingListener</code>, confirm availability shows <code>GOOGLE_RENDERED</code>, and then launch the flow with a developer billing option attached:</p>
<pre><code class="language-kotlin">val developerBillingOptionParams = DeveloperBillingOptionParams.newBuilder()
    .setBillingProgram(BillingProgram.BILLING_CHOICE)
    .build()

val billingFlowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(productDetailsParamsList)
    .enableDeveloperBillingOption(developerBillingOptionParams)
    .build()

billingClient.launchBillingFlow(activity, billingFlowParams)</code></pre>
<p>The call to <code>enableDeveloperBillingOption()</code> is what tells Google to show the choice screen instead of going straight to Play’s payment sheet. From here the flow splits down two paths depending on what the user taps:</p>
<ul>
<li><strong>The user picks Google Play Billing.</strong> The purchase completes through Google, and your existing <code>PurchasesUpdatedListener</code> receives the result exactly as it would without Billing Choice.</li>
<li><strong>The user picks your billing option.</strong> Google invokes the <code>DeveloperProvidedBillingListener</code> you registered and hands you a <code>DeveloperProvidedBillingDetails</code> object that carries an <code>externalTransactionToken</code>. That token is your receipt to take payment through your own system and later report the sale.</li>
</ul>
<p>The split is the whole point. One code path keeps the familiar Google Play purchase, and the other hands control to you with a token already minted. You did not have to draw a screen or generate the token yourself, because in this scenario Google did both.</p>
<h2><strong>Scenario 1B: You render the choice, payment happens in app</strong></h2>
<p>When you render the choice screen, you trade convenience for control. You no longer register a <code>DeveloperProvidedBillingListener</code>, because there is no Google screen to call you back from. Instead, you gather the pieces you need to draw a screen that treats Google Play fairly, then mint your own token when the user chooses you.</p>
<p>The first piece is the Google Play side of the screen. You ask Billing for the image and loyalty message to display for the Play option with <code>getBillingChoiceInfo()</code>:</p>
<pre><code class="language-kotlin">val params = GetBillingChoiceInfoParams.newBuilder()
    .setBillingProgram(BillingProgram.BILLING_CHOICE)
    .setPlayBillingChoiceImageLayout(GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_FOUR_BY_ONE)
    .build()

val (billingResult, info) = billingClient.getBillingChoiceInfo(params)
if (billingResult.responseCode == BillingResponseCode.OK &amp;&amp; info != null) {
    val imageUrl = info.playBillingChoiceImageUrl
    val loyaltyMessage = info.playBillingLoyaltyInfo
    \/\/ Render these into your own choice screen.
}</code></pre>
<p>The <code>playBillingChoiceImageUrl</code> is the payment method artwork for Google Play, and <code>playBillingLoyaltyInfo</code> is any loyalty message Google wants shown beside its option, such as a Play Points rewards message. You must load these every time you draw the screen rather than caching them, a requirement covered in the UX section below. For callback style code there is also <code>getBillingChoiceInfoAsync()</code>, which delivers the same <code>PlayBillingChoiceInfo</code> to a listener, and that is the form the UX guidelines reference.</p>
<p>Once the user taps your option, you generate a reporting token with <code>createBillingProgramReportingDetails()</code>, declaring the billing type as in-app:</p>
<pre><code class="language-kotlin">val params = BillingProgramReportingDetailsParams.newBuilder()
    .setBillingProgram(BillingProgram.BILLING_CHOICE)
    .setDeveloperBillingType(DeveloperBillingType.IN_APP)
    .build()

val (billingResult, details) =
    billingClient.createBillingProgramReportingDetails(params)
if (billingResult.responseCode != BillingResponseCode.OK) return
val transactionToken = details?.externalTransactionToken</code></pre>
<p>Before you charge the user, you also call <code>showBillingProgramInformationDialog()</code>, which presents the legally required disclosure for transacting outside Google Play Billing. The dialog request carries the <code>BillingProgram</code> and the transaction token you just generated. After the disclosure, you run your own payment flow and keep the <code>externalTransactionToken</code> for the server-side report that follows every alternative sale.</p>
<h2><strong>Scenarios 2A and 2B: Sending users to an external web link</strong></h2>
<p>The external link scenarios replace your in app payment with a redirect to your website. In both of them you generate the token yourself with <code>createBillingProgramReportingDetails()</code>, declaring <code>DeveloperBillingType.EXTERNAL_LINK</code> instead of <code>IN_APP</code>. This is the one place the A versus B shorthand bends: even in Scenario 2A, where Google renders the choice screen, you mint the token before launching rather than receiving it through the listener, because the token has to travel with the redirect. You should only attempt these scenarios when availability reported <code>isExternalLinkAvailable</code> as true.</p>
<p>In <strong>Scenario 2A</strong>, Google renders the choice screen, so you still launch through <code>launchBillingFlow()</code>. The difference is that your developer billing option now carries the destination URI, the token you just generated, and a launch mode:</p>
<pre><code class="language-kotlin">val developerBillingOptionParams = DeveloperBillingOptionParams.newBuilder()
    .setBillingProgram(BillingProgram.BILLING_CHOICE)
    .setLinkUri(Uri.parse(&quot;https:\/\/www.example.com\/external\/purchase&quot;))
    .setExternalTransactionToken(transactionToken)
    .setLaunchMode(DeveloperBillingOptionParams.LaunchMode.LAUNCH_IN_EXTERNAL_BROWSER_OR_APP)
    .build()</code></pre>
<p>You attach this to <code>BillingFlowParams</code> with <code>enableDeveloperBillingOption()</code> and call <code>launchBillingFlow()</code> as before. If the user picks Google Play, the normal purchase happens. If they pick your option, Google opens your link and the token travels with the redirect.</p>
<p>In <strong>Scenario 2B</strong>, you render the choice screen yourself, so there is no billing flow to launch. You send the user out with a dedicated call, <code>launchExternalLink()</code>:</p>
<pre><code class="language-kotlin">val params = LaunchExternalLinkParams.newBuilder()
    .setBillingProgram(BillingProgram.BILLING_CHOICE)
    .setLinkUri(yourLinkUri)
    .setLinkType(LaunchExternalLinkParams.LinkType.LINK_TO_DIGITAL_CONTENT_OFFER)
    .setLaunchMode(LaunchExternalLinkParams.LaunchMode.LAUNCH_IN_EXTERNAL_BROWSER_OR_APP)
    .setExternalTransactionToken(transactionToken)
    .build()

billingClient.launchExternalLink(activity, params) { billingResult -&gt;
    if (billingResult.responseCode == BillingResponseCode.OK) {
        \/\/ The user is on the way to your site. Report the sale when it completes.
    }
}</code></pre>
<p>The <code>LinkType.LINK_TO_DIGITAL_CONTENT_OFFER</code> describes what waits on the other side of the link, and the launch mode opens it in an external browser or app. The result callback only confirms that the link launched successfully. It does not tell you the purchase succeeded, because that happens on your website, outside the app. Completing and recording the sale is your responsibility from this point on.</p>
<h2><strong>The external transaction token: reporting every alternative sale</strong></h2>
<p>Across all four scenarios, the same object keeps appearing: the <code>externalTransactionToken</code>. It is the thread that connects a tap on the choice screen to a line item in your Play revenue report, and understanding it is what separates a working integration from a compliant one.</p>
<p>Every alternative billing transaction, whether it happened in app or through an external link, must be reported back to Google Play. The token is how Google links your report to the original choice. It also encodes the <code>DeveloperBillingType</code>, so Google knows whether to treat the sale as an in app transaction or an external link transaction when it calculates the service fee. Where the token comes from depends on the scenario: in Scenario 1A it arrives through the <code>DeveloperProvidedBillingListener</code>, inside the <code>DeveloperProvidedBillingDetails</code> object, and in the other scenarios you generate it yourself with <code>createBillingProgramReportingDetails()</code>.</p>
<p>The report itself is a server side call to the Google Play Developer API, on the <code>externaltransactions</code> resource. After the user pays through your system, your backend calls <code>createexternaltransaction</code> with the token in the request body. The body differs by product type. A one time product uses a <code>oneTimeTransaction</code> wrapper, while a subscription uses a <code>recurringTransaction</code> wrapper that also declares the subscription type:</p>
<pre><code class="language-kotlin">{
  &quot;recurringTransaction&quot;: {
    &quot;externalTransactionToken&quot;: &quot;the_token_from_the_client&quot;,
    &quot;externalSubscription&quot;: { &quot;subscriptionType&quot;: &quot;RECURRING&quot; }
  }
}</code></pre>
<p>There is a distinction worth holding onto for subscriptions. The token is required for the first transaction in a recurring purchase, but renewals do not get a fresh token. Instead, you report each renewal against the original sale using <code>initialExternalTransactionId</code>, the identifier of that first transaction. The API also exposes <code>getexternaltransaction</code> to read a transaction back and <code>refundexternaltransaction</code> to report a refund, so the full lifecycle of an alternative sale is mirrored on Google’s side.</p>
<p>The service fee that results from these reports varies by transaction type and by geography, with the European Economic Area called out specifically, and the published rates live in Google’s external offers program documentation rather than in the Billing Library reference. The takeaway for your architecture is that choosing alternative billing does not remove the service fee. It moves the responsibility for declaring each sale onto your own backend.</p>
<h2><strong>Subscription upgrades and downgrades</strong></h2>
<p>Subscriptions add one more case: what happens when a user who already bought through your billing system upgrades or downgrades their plan. You do not want to show the choice screen again, because the user already made their choice on the original purchase, and Billing Choice preserves it.</p>
<p>You signal this by carrying the original transaction’s identifier into the replacement flow with <code>setOriginalExternalTransactionId()</code> on <code>SubscriptionUpdateParams</code> :</p>
<pre><code class="language-kotlin">val developerBillingOptionParams = DeveloperBillingOptionParams.newBuilder()
    .setBillingProgram(BillingProgram.BILLING_CHOICE)
    .build()

val billingFlowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(newPlanProductDetailsList)
    .setSubscriptionUpdateParams(
        BillingFlowParams.SubscriptionUpdateParams.newBuilder()
            .setOriginalExternalTransactionId(externalTransactionId)
            .build()
    )
    .enableDeveloperBillingOption(developerBillingOptionParams)
    .build()

billingClient.launchBillingFlow(activity, billingFlowParams)</code></pre>
<p>Because the original choice is preserved, no choice screen appears for the replacement. The user moves directly into the new plan through the billing system they already selected. Two identifiers are in play here, and they are not the same thing. <code>setOriginalExternalTransactionId()</code> runs on the client and names the prior purchase this upgrade replaces, which is what lets Google skip the choice screen. <code>initialExternalTransactionId</code> is a server side reporting field that chains a subscription’s renewals back to its own first transaction. So the new plan is still a new purchase: once the upgrade or downgrade completes, you report it as a new transaction with its own <code>externalTransactionToken</code>, and that transaction then becomes the <code>initialExternalTransactionId</code> its later renewals reference.</p>
<h2><strong>Designing a fair choice screen</strong></h2>
<p>When you render your own choice screen (the B scenarios), the UX guidelines stop being advice and become requirements you agreed to by declaring <code>DEVELOPER_RENDERED</code> in Play Console. The single principle behind every rule is that the user must be able to compare the two options fairly, with no thumb on the scale toward your billing system.</p>
<p>The concrete requirements:</p>
<ul>
<li><strong>Label who is responsible.</strong> Each option must clearly identify the authorized entity, app name, or developer name, so the user understands who fulfills the purchase and handles support.</li>
<li><strong>Show the right icons.</strong> Display the Google Play icon in full color against a neutral light or dark background per Google’s brand guidelines, and display only your own app icon or brand logo for your option. You may not dress your option in Google’s branding.</li>
<li><strong>Fetch payment method logos live.</strong> Use the assets from <code>getBillingChoiceInfoAsync()</code> to show Google Play’s payment methods, and treat your own payment methods the same way. You must fetch and display these each time and never cache or alter them. If you cannot determine your own payment methods, you may omit them, but you must still show Google Play’s.</li>
<li><strong>Keep the buttons equal and close.</strong> Button sizes, text size, font style, contrast, tap targets, and in-button logo sizing should be equivalent, and the two buttons must sit in close proximity so the user can compare them side by side.</li>
<li><strong>Use equivalent calls to action.</strong> The primary button labels must be equivalent and consistent, for example “Pay with Google Play” next to “Pay with [your service].” You may present differentiated offers, but if you do, Google Play’s loyalty benefits must be shown in an equivalent manner.</li>
<li><strong>Treat loyalty equally.</strong> If you display loyalty information, show it for both options with equal treatment, and pull Google Play’s loyalty message from <code>getBillingChoiceInfoAsync()</code> each time without caching it.</li>
<li><strong>Disclose the redirect.</strong> If your option sends the user to a website, you must clearly and prominently inform them they are leaving the app, with explicit wording such as “You’ll be redirected to a web page.”</li>
</ul>
<p>A nuance to note: the guidelines phrase the broad visual equality clause with “should,” while proximity, equivalent calls to action, in button logo sizing, and the no caching rules use “must.” When you are uncertain whether something is mandatory, the safe reading is to treat the equality requirements as binding, because the program’s entire purpose is to avoid nudging users away from Google Play Billing.</p>
<h2><strong>Supervised users and parental controls</strong></h2>
<p>One behavior is handled for you. For supervised users, Google Play automatically displays the appropriate parental control screen during the Billing Choice flow. The integration notes call this out for each of the four scenarios, so expect it whether Google renders the choice screen or you do. You do not build or trigger it yourself, so it is worth knowing the extra screen exists before it surprises you during testing with a supervised account.</p>
<h2><strong>What this means for your monetization</strong></h2>
<p>Stepping back from the API, Billing Choice is a trade. Google Play Billing handed you a great deal of infrastructure for its fee: payment processing, tax handling, receipts, refunds, renewals, fraud protection, and the entire reporting pipeline. Billing Choice lets you take some of that back, but only by taking on the work that came with it.</p>
<p>When a user picks your option, you own the payment processing, the fulfillment, the customer support, the refunds, and the server side reporting that keeps you compliant. The service fee does not vanish, it is assessed from the transactions you report through the <code>externaltransactions</code> API. And Google Play Billing remains on the screen as a first-class choice for every user, every time, which means your alternative has to earn its selection rather than being the only door.</p>
<p>That framing helps decide which scenario fits. If you mainly want to reduce friction without standing up a payment stack, the Google rendered scenarios (1A and 2A) carry the least integration burden, because Google draws the screen and mints the token. If you already run billing infrastructure on the web and want to route users there, an external link scenario (2A or 2B) reuses what you have. The fully developer rendered, in app scenario (1B) gives you the most control over the experience and demands the most from you in return: a compliant choice screen, your own payment flow, the disclosure dialog, and the reporting backend, all built and maintained by your team.</p>
<h2><strong>Conclusion</strong></h2>
<p>In this article, you’ve explored how Billing Choice reshapes the Android purchase flow into a fair choice between Google Play Billing and your own alternative. You saw the two decisions that produce the four integration scenarios, the <code>BillingProgram</code> API for enabling the program and reading its availability, how each scenario launches its purchase, the external transaction token that ties every alternative sale to a <code>createexternaltransaction</code> report, how subscription replacements preserve the user’s original choice, and the UX rules that govern a screen you render yourself.</p>
<p>Understanding these internals helps you make the decision Billing Choice actually asks of you, which is not “should I use alternative billing” but “which of the four scenarios matches what my team can build and operate.” The token flow, the reporting obligation, and the fairness requirements are the real cost of admission, and seeing them clearly up front is what prevents an integration that works in a demo but falls out of compliance in production.</p>
<p>Whether you are adding a single external link to an existing web checkout, building a full in app billing system behind your own choice screen, or simply letting Google render the choice while you collect payment, this foundation lets you reason about the program as a set of deliberate trade offs rather than a single switch to flip. Choose the scenario that fits, report every sale, and keep the choice honest.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Move your rating prompt out of onboarding — or risk rejection]]></title>
      <link>https://www.revenuecat.com/blog/engineering/dont-prompt-ratings-during-onboarding</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/dont-prompt-ratings-during-onboarding</guid>
      <pubDate>Thu, 18 Jun 2026 15:06:00 GMT</pubDate>
      <dc:creator><![CDATA[Perttu Lähteenlahti]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[Apple is rejecting apps that ask for ratings during onboarding. ]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/bd956ea8b2d7d12e7791c56b6fc7c1c30ef67c7c-1600x800.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>A common pattern in many new and existing apps is to prompt users for a review during the onboarding process. However, next time you submit your app for review, Apple will reject it if you’re still using this pattern.</p>
<h2>Asking ratings too early is considered manipulation</h2>
<p>Apple has started rejecting apps under <a href="https://developer.apple.com/app-store/review/guidelines/">App Review Guideline 5.6.3</a>, which states:</p>
<p><em>…Manipulating any element of the App Store customer experience such as charts, search, reviews, or referrals to your app erodes customer trust and is not permitted.</em></p>
<p>This isn’t a new guideline, but Apple hasn’t previously applied it to onboarding in this way. Prompting for a review early in the onboarding flow was a common, if questionable, tactic. Some apps were collecting thousands of five-star ratings before users had done anything meaningful with the app.</p>
<p>It is not clear if Apple will go through apps that are already in the App Store, but apps using this pattern will get rejected during the review process for new versions of the app. <a href="https://x.com/arielmichaeli/status/2067287069819355338">It might also be that Apple was already ignoring ratings that occur during onboarding</a> by comparing the time between install and rating; nullifying the ASO benefits.</p>
<h2>What should you do instead?</h2>
<p>The first step to fixing this is pretty simple: just remove all the rating and review prompts from your onboarding flows. The second step is more complicated; you should prompt only after the user has meaningfully engaged with your app. Ask for a review when there’s actually something to review. So when exactly?</p>
<p>Apple’s own Human Interface Guidelines say to prompt at “natural and happy moments”: after a user has completed something, achieved something, or had a genuine reason to form an opinion about your app.</p>
<p>That sounds straightforward, but in practice most apps don’t have a systematic way to know when that moment actually is. A user who just opened your app for the first time isn’t there yet. Neither is someone who made it through onboarding but hasn’t done anything meaningful.</p>
<p>Now, if you’re wondering how to do that, you’re in luck. <a href="https://www.revenuecat.com/blog/engineering/how-to-hack-your-app-store-ratings/">We’ve actually written about it before in this guide for ethically hacking your App Store ratings</a>. In the guide, you learn how to build “a happiness engine” that tracks user engagement with the app, and prompts for a review when you’re most likely to get a positive one. The same guide applies to the Google Play Store as well (although Google is not yet limiting).</p>
<h2>Bottom line</h2>
<p>Stop asking users to review your app during the onboarding flow. Apple will reject your app if they catch you doing it. Most likely they were already filtering out these ratings, by comparing the interval between app install and time of rating.</p>
<p>Instead prompt users to review the app after they’ve meaningfully engaged with it. Track those engagements and build an engine for prompting for a review when the user has both engaged with your app and is happy with their experience.</p>
<ul>
<li><a href="https://www.revenuecat.com/blog/engineering/how-to-hack-your-app-store-ratings/">How to hack your app store ratings (ethically)</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Monthly plans might be your best option]]></title>
      <link>https://www.revenuecat.com/blog/growth/monthly-subscriptions-when-to-offer</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/monthly-subscriptions-when-to-offer</guid>
      <pubDate>Thu, 18 Jun 2026 10:00:09 GMT</pubDate>
      <dc:creator><![CDATA[Daphne Tideman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[The hidden cost of long commitment periods]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/132c0102a08f131c73ec356d8a8e04a42702d642-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Everyone in subscriptions has an opinion on annual plans. <em>They’re better for LTV, better for cash flow, better for churn…</em></p>
<p>The data says so, apps love them, and honestly, the data isn’t wrong. Annual subscriptions across all categories perform better in terms of retention according to the <a href="https://www.revenuecat.com/state-of-subscription-apps/">State of Subscription Apps (SOSA) 2026 report</a>:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/80719a44203603aeb3acce2f856f38ee7c280cb8-840x539.png" alt="Annual subscriptions across all categories perform better in terms of retention according to the SOSA 2026 report:"/><figcaption>Annual subscriptions across all categories perform better in terms of retention</figcaption></figure>
<p>But I think we’ve overcorrected.</p>
<p><a href="https://www.revenuecat.com/blog/growth/annual-subscriptions-apps-pros-cons/#what-are-the-benefits-of-annual-subscriptions">Annual subscriptions definitely have their advantages</a>, but I think we are a bit mean to monthly subscriptions. I’m here to stand up for monthly subscriptions, give you the good and bad just like I’ve done for <a href="https://www.revenuecat.com/blog/growth/annual-subscriptions-apps-pros-cons/">annual subscriptions</a>, <a href="https://www.revenuecat.com/blog/growth/weekly-subscriptions/">weekly subscriptions</a>, and <a href="https://www.revenuecat.com/blog/growth/lifetime-subscriptions/">lifetime subscriptions</a> (sorry, monthly subscriptions, I didn’t mean to leave you until last).</p>
<p>It’s not that monthly plans are always better; it’s that there are specific situations where a monthly subscription will serve your app better than an annual one. And most founders never stop to ask which situation they’re actually in.</p>
<h2>First, retention and popularity don’t equal revenue</h2>
<p>Yes, annual plans retain better. Look at one-year retention across categories, and they win almost everywhere. Nobody is disputing that. In most categories, annual plans are the most popular option. According to <a href="https://www.revenuecat.com/state-of-subscription-apps/">SOSA 2026</a>:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/ad037bde512ac3455fda7850487c24a5788e96a4-893x436.png" alt="In most categories, annual plans are the most popular option. "/><figcaption>In most categories, annual plans are the most popular option.</figcaption></figure>
<p>Except in the productivity and gaming categories. But retention isn’t the same as revenue.</p>
<p>In most categories, monthly subscriptions generate revenue equal to or exceeding their share of subscriptions. Check out the revenue for monthly subscriptions in productivity:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/1dc8614fde4217700b7e7e045787ec5bceccac6e-900x413.png" alt="Revenue mix for monthly subscriptions across categories"/><figcaption>Revenue mix for monthly subscriptions across categories</figcaption></figure>
<p>The productivity category is the clearest example: 76.7% of subscriptions are monthly, but 90.7% of revenue comes from those monthly plans.</p>
<p>That gap exists because monthly subscribers typically pay more on a monthly basis, and while they do churn at higher rates, they’re also more likely to reactivate later on.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/b54f6bc09c051dfa2f7a95f88af969cd47a27b09-948x672.png" alt="Reactivation rate within 1 year by app category"/><figcaption>Monthly subs tend to pay more, churn more, but reactivate more</figcaption></figure>
<p>Your <a href="https://www.revenuecat.com/docs/dashboard-and-metrics/charts/realized-ltv-per-paying-customer-chart">ARPPU</a> (Average Revenue Per Paying User) over time can actually be higher than you’d expect.</p>
<p>The reason the industry obsesses over annuals is that they create a reliable, predictable number on your dashboard. And that number feels good. But it’s worth asking: is that number telling you the truth?</p>
<h2>The uncomfortable truth about annual subscriptions</h2>
<p>Here’s something nobody says out loud: <strong>annual subscriptions can mask a broken product</strong>.</p>
<p>Someone who paid up front might not be using your app. They might regret the purchase. They might be planning to cancel at renewal. They may have stopped opening your app entirely, but your dashboard still shows “active subscriber” for another 9 months.</p>
<p><strong>That’s not retention. That’s delayed churn.</strong></p>
<p>And for early-stage apps, delayed churn is dangerous. It means you’re getting false signals about product-market fit. Your retention metrics look healthy. Your team feels good. And then renewal month arrives, and you discover the cliff you didn’t know was coming.</p>
<p>Monthly subscribers who stay are actively choosing you, every single month. That signal is worth more than an annual commitment made once, under the influence of a good discount and some well-timed push notifications.</p>
<h2>So when should you actually offer monthlies?</h2>
<p>There are five situations where monthly subscriptions can be your secret weapon.</p>
<h3>1. When you are still learning</h3>
<p>When someone commits to a quarter or a year, you don’t really know if they’re genuinely happy until renewal time. And if they’re not, you often won’t find out why until months later, by which point their memory has faded, and their feedback turns vague. You end up with things like “I stopped using it as much” or “I just found a better alternative for me.”</p>
<p>With monthly subscriptions, each renewal is an active decision, so users who stay are actively choosing you. Monthly subscriptions create faster feedback loops:</p>
<ul>
<li>You see churn patterns in weeks, not months</li>
<li>When someone leaves, you can ask why while the experience is still fresh</li>
<li>Users who stay are actively choosing you, giving you a cleaner signal on what’s working</li>
</ul>
<p>Yes, monthly churn tends to be higher, but that’s the point. You’re trading a vanity metric for something more valuable: speed of learning.</p>
<p>Why not just look at usage patterns? Identify who is or could be at risk? For startups, this can be challenging:</p>
<ul>
<li>A data infrastructure takes time and money to set up</li>
<li>You aren’t always sure what the best predictors of retention are</li>
<li>You don’t always know yet what normal usage patterns are to identify a drop in usage</li>
</ul>
<p>Meanwhile, monthly subscribers who stay are genuinely choosing you, repeatedly. That signal is worth more than an annual commitment made once under the influence of a good discount and some great marketing. Think about what you can learn from monthly subscribers:</p>
<ul>
<li><strong>Who’s churning?</strong> Is it a specific customer segment? A specific acquisition channel?</li>
<li><strong>When are they churning?</strong> Month 1 (onboarding issue), month 2-3 (habit formation), or later (ongoing value).</li>
<li><strong>What do they say when they leave?</strong> You can ask while it’s fresh.</li>
</ul>
<p>Start with offering monthlies if you are still learning whether you have strong retention and what drives it. It’ll help you learn what drives product-market fit far faster and has an extra benefit for startups where trust is low.</p>
<p>For some brands, offering only monthly (or making it the default) for the first 6-12 months could dramatically accelerate <a href="https://www.revenuecat.com/blog/growth/learning-roadmap-for-apps/">the rate at which you learn what’s working</a>.</p>
<h3>2. When trust is low</h3>
<p>For startups, you don’t have a huge number of reviews or well-known individuals shouting “use this app” to the world (well, most don’t). So monthly subscriptions can be an easier first step than annual plans as you build trust, refine onboarding, and get sharper at communicating value.</p>
<p>In that context, asking someone to commit to a year upfront is a big ask. Monthly plans lower the barrier significantly.</p>
<p>Even with a free trial, I consistently see users default to monthly first — particularly in categories where “will I actually use this?” is a real question, like productivity, health and fitness, and education. They try it, and if it sticks, they upgrade to an annual later.</p>
<p>This is also worth thinking about for <a href="https://www.revenuecat.com/blog/growth/web-to-app-funnels/">web-to-app flows</a>.</p>
<p>When a user hasn’t seen your app yet — they’ve come through a web landing page, they don’t know the product — the commitment ask is even higher. App stores provide a layer of trust: standardized billing, the familiarity of Apple or Google Pay, and that quick double-click checkout. On the web, that safety net is thinner. In that context, a monthly subscription becomes an easier first step.</p>
<p>There’s another benefit here, too: more conversions means more data. More signals to test and optimize your funnel with. Every monthly subscriber who converts is a learning opportunity that an unconverted annual prospect simply doesn’t give you.</p>
<h3>3. When your users prefer it</h3>
<p>Not every user wants to pay for a year upfront. For some, it’s a financial preference. For others, it’s cultural.</p>
<p>In MEA and Asia-Pacific, monthly plans are notably more popular than in Western markets.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/c63f60eb9c702db5229b0dc7a8112ca3832d34c1-951x669.png" alt="Plan mix by geography "/><figcaption>In MEA and Asia-Pacific, monthly plans are notably more popular than in Western markets.</figcaption></figure>
<p>The preference for pay-as-you-go and <a href="https://www.webintravel.com/apac-at-the-heart-of-worldlines-ambitions-to-enter-high-growth-markets">digital wallet payments</a> makes monthly subscriptions a more natural fit. And it’s not necessarily about price sensitivity, it’s about how people prefer to manage money in those regions.</p>
<p>Geography isn’t the only factor that comes into play. Certain generations prefer shorter subscriptions. We saw this with weekly subscriptions. <a href="https://www.pymnts.com/earnings/2023/tinder-taps-short-term-subscriptions-to-tackle-gen-zs-commitment-phobia/">Gen Z seems to prefer them due to a higher commitment phobia</a> (my favorite thing about this research was that it was done by Tinder… ironic for a dating app). So, certain generations may also be more comfortable with a monthly subscription than with an annual one. It seems Gen Z and younger millennials (under 34) value shorter subscriptions with greater flexibility, while older generations tend to prefer annual subscriptions for stability and discounts.</p>
<p>If your audience skews younger or you’re building for emerging markets, defaulting to annual may actually be suppressing your conversion rate.</p>
<h3>4. When your use case suits it</h3>
<p>We all love the idea of users using our apps forever and forever, but sometimes it isn’t needed.</p>
<p>Take therapy, when I used BetterHelp, a therapy platform, I never for a second considered an annual subscription. Costs aside, I hoped I wouldn’t need it for a full year, and that proved true. After 3-4 months, I stopped using the platform, happily churning.</p>
<p>Another great example is Hinge, the dating app, which literally advertises that they are designed for short-term usage:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/31b1a2a9d3616ee2c80f5b177c90e081d50a7032-1200x1800.png" alt="Another great example is Hinge, the dating app, which literally advertises that they are designed for short-term usage."/><figcaption>Source: Creative Review</figcaption></figure>
<p>You need to think about your users’ expected use case, and whilst you can extend it or grow with them, for some users, you might just be a short-term fix, and that is okay.</p>
<p>Some apps are genuinely short-term solutions:</p>
<ul>
<li>Language learning before a trip.</li>
<li>A fitness app to prep for a specific event</li>
<li>A productivity tool for a project that has an end date</li>
</ul>
<p>Trying to lock those users into annual subscriptions doesn’t increase their lifetime value; it increases their likelihood of feeling trapped and leaving a negative review.</p>
<p>Think honestly about your users’ expected use case. If your core value proposition is a transformation that happens over three to six months, a monthly (or quarterly) subscription might actually serve both parties better than an annual one.</p>
<p>Simpler is usually better. Look at your average retention and choose two subscription durations that match your actual use case, rather than offering every option at once.</p>
<h3>5. When you want to drive revenue up, not just retain users</h3>
<p>This one surprises people. Usually, for most apps, even <a href="https://www.revenuecat.com/docs/dashboard-and-metrics/charts/realized-ltv-per-paying-customer-chart">unbounded ARPPU</a> is lower for monthly subscriptions.</p>
<p>Yet, Spotify doesn’t offer annual subscriptions. Netflix doesn’t either. These are two of the most successful subscription businesses on the planet, and they’ve built their entire model on monthly billing.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/9f0984b969957eb99ca27535eaa674bf2e6019c1-1220x1088.png" alt="Spotify's plan mix - no annual subscriptions"/><figcaption>Spotify doesn’t offer annual subscriptions</figcaption></figure>
<p>Part of that is product complexity — layering monthly/annual on top of individual/duo/family plans creates more confusion than value. But I think there’s something more interesting going on.</p>
<p>Monthly-only pricing works when you have two things simultaneously: high early churn and very stable long-term retention. The users who stay past a certain point become genuinely sticky — content keeps updating, the habit is formed, and the switching cost is real. For those users, not having an annual discount option means they pay full price every month, indefinitely. Your LTV on retained users actually goes up.</p>
<p>But Peloton is an interesting case study for this. When I <a href="https://www.revenuecat.com/blog/growth/peloton-retention-takeaways/">canceled my Peloton subscription</a>, I was frustrated by the lack of an annual option — the monthly price felt hard to justify. But Peloton didn’t need to win that battle, because the hardware investment locks users in and makes them willing to pay a premium monthly rate anyway.</p>
<p>The lesson isn’t “don’t offer annuals.” It’s about understanding what actually creates stickiness in your product before you default to discounting your way to retention.</p>
<p>Some of these apps may initially offer an annual subscription option and then, over time, slowly shift back to monthly once they’ve maximized revenue and retention.</p>
<h2>How do you decide what to offer?</h2>
<p>Most apps I work with default to annual as the primary option without ever asking: Does this actually match where we are right now?</p>
<p>Here’s the diagnostic I’d suggest:</p>
<p><strong>Offer monthly as your primary option (or only option) if:</strong></p>
<ul>
<li>You’re pre-PMF or still learning what drives retention</li>
<li>Your brand is new, and trust is still being built</li>
<li>Your primary market is MEA, APAC, or your audience skews under 34</li>
<li>Your use case has a natural endpoint within 3–6 months</li>
<li>Your conversion rate is low, and you need more data to optimize the funnel</li>
</ul>
<p><strong>Lead with annual, but keep monthly available if:</strong></p>
<ul>
<li>You have strong evidence of what drives long-term retention</li>
<li>Your product creates a genuine long-term habit</li>
<li>Your audience is used to SaaS-style annual commitments (productivity, B2B-adjacent tools)</li>
<li>You’re confident in your onboarding and activation — users who start are getting to value</li>
</ul>
<p><strong>Consider exclusively monthly if:</strong></p>
<ul>
<li>You have exceptionally stable long-term retention and don’t want to discount it away</li>
<li>Your product model naturally supports it (premium content, ongoing service)</li>
<li>You’re deliberately running a lean paywall to maximize learning before scaling</li>
</ul>
<h2>If you’re currently offering only annual, how can you actually test this?</h2>
<p>So you’ve read all of this, and you’re thinking: fine, maybe I should give monthly a shot. You kind of have a point, Daphne.</p>
<p>But how do you do it without tanking your metrics?</p>
<p>A few things to consider before you start:</p>
<h3>1. Define what success looks like <em>before</em> you start</h3>
<p>If you test monthly and see higher conversion but lower ARPPU in the short-term, is that a win or a loss? The answer depends on where you are.</p>
<p>For an early-stage app still learning retention, more converted users and faster feedback loops might be worth more than a higher per-subscriber ARPPU number right now.</p>
<p>Know what you’re optimizing for before you interpret the results.</p>
<h3>2. Don’t just add monthly and hope for the best</h3>
<p>The most common mistake is simply bolting a monthly option onto a paywall that was clearly designed for an annual plan. Where you place each option and how you promote it should depend on your goal.</p>
<p>If you’re trying to learn, and your paywall currently defaults to annual with a prominent “save X%” badge, then adding a small, secondary monthly option at the bottom won’t tell you much at all.</p>
<p>If, instead, you’re trying to meet users where they are — or reduce friction in low-trust environments — then monthly needs to be more visible and genuinely considered. You can still position annual as the better value on a per-month basis, but monthly has to get a fair test if you want meaningful insights from it.</p>
<h3>3. Think carefully about trial interaction</h3>
<p>A <a href="https://www.revenuecat.com/blog/growth/should-your-app-stop-offering-free-trials/">free trial</a> plus a monthly plan is a very different psychological ask from a free trial plus an annual plan. It reduces perceived risk even further, but it can also lead to lower-quality subscribers.</p>
<p>For that reason, some teams deliberately choose not to include a free trial on the monthly tier.</p>
<p>If your trial-to-paid conversion is lower than you’d like, testing a monthly entry point is one of the cleanest ways to diagnose whether the issue is price sensitivity or commitment aversion. In some cases, you might even start by offering a trial on the monthly plan first, and then remove it later once you understand how users behave.</p>
<h3>4. Consider the monthly-to-annual upgrade path</h3>
<p>One of the strongest arguments for offering a monthly plan isn’t the plan itself — it’s what happens after. Users who start on a monthly plan, experience real value, and then upgrade to annual are often your highest-quality subscribers. They’ve effectively chosen you twice.</p>
<p>If you can design a well-timed upgrade nudge — whether that’s in-app, after a key activation moment, or around the one-month mark — monthly stops being a “discounted alternative” and becomes a lower-friction entry point that feeds into annual revenue over time.</p>
<p>You also want to keep in mind that reactivation data, <a href="https://www.revenuecat.com/webinars/win-back-webinar-2024/">getting monthlies to reactivate</a>, works better when you have the lifecycle marketing in place to support it.</p>
<h2>Give monthly subscriptions a consideration, please</h2>
<p>Annual subscriptions feel like a win: higher LTV, stronger retention curves, cash upfront. It’s easy to see why teams default to them.</p>
<p>But too many apps push annual plans before they’ve actually earned the right to ask for that level of commitment — before they really understand whether retention is durable, and before they’ve built enough trust for a 12-month ask to feel safe rather than risky.</p>
<p>Monthly subscriptions aren’t just a concession to users who won’t commit. In the right context, they’re a more deliberate product decision: one that gives you cleaner data, lower conversion friction, and faster feedback loops that help you build something people genuinely want to keep paying for, month after month.</p>
<p>Give them a chance – not as a fallback, but as a deliberate choice.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[He turned down 75K for his app with 12K in sales. It hit $1M two years later.]]></title>
      <link>https://www.revenuecat.com/blog/growth/eric-duffett-shot-pattern-launched-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/eric-duffett-shot-pattern-launched-podcast-2026</guid>
      <pubDate>Wed, 17 Jun 2026 13:17:02 GMT</pubDate>
      <dc:creator><![CDATA[Charlie Chapman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Eric Duffett had done $12,000 in total sales when a company offered $75,000 for his side-project golf app — and told him they'd crush him if he said no.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/9a18a4e5cfe31e5a3d311f741c73ad8704a4c3a3-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<h2><strong>The $5,000 bet that changed everything</strong></h2>
<p>Shot Pattern started as a hobby. Eric Duffett had just stepped down from coaching basketball because his second child was on the way, and he sat still for about five seconds before opening his laptop. He wanted to see if he could build a simple golf tool — one that would draw two lines on a satellite map showing the width of his landing area. No golf course data, no hole layouts. Just lines on Apple Maps.</p>
<p>It was $20 a year. People would download it, open it at their house, and see it pointing at their backyard. “That’s surprising,” Eric says. But on the golf course, it worked — like a compass for shot strategy.</p>
<p><a href="https://www.youtube.com/watch?v=wXDqhhSNJQ4">Watch on YouTube</a></p>
<p>Within two months, something felt different. Downloads and trials were coming in without Eric understanding where they came from. The reason: serious golfers were already doing this exact thing manually on Google Maps, measuring fairway widths with the built-in ruler tool. Eric had automated a workflow that already existed in a community of strategy-obsessed players.</p>
<p>Then he hit a wall. To make the app actually useful — to give it awareness of golf courses, holes, and hazards — he needed to buy course data. The only option was a $5,000 upfront API purchase. He’d made about $1,000 in total sales. His wife asked the obvious question: “Is this a hobby or is this a business?”</p>
<p>He paid the $5,000. And buried in that API data, he found something he didn’t know he’d purchased: geo-point outlines of every fairway, bunker, water hazard, and green. That was the missing piece for running shot simulations — the “moneyball” feature that would define the app.</p>
<h2><strong>“This feature is highly illegal”</strong></h2>
<p>Eric built a half-finished prototype of the simulation feature, took a screenshot, and posted it on Twitter: “Hey, this feature is highly illegal if you were to use it during tournament play, but it’s coming soon to Shot Pattern.”</p>
<p>That single tweet was the first time anything he posted got real momentum with a cold audience. Phone calls started coming in from golf influencers and coaches. “Oh, now we’re off to the races,” he says. “Now we have something.”</p>
<p>Shortly after, he got a different kind of phone call. A respected golf company — people Eric admired, “man-crush in the golf world” level — said they’d used the app, loved it, and wanted to buy it. They’d been thinking of building something similar. “What’s your number?”</p>
<p>Eric asked for $250,000. He’d done $12,000 in total sales. They countered at $75,000 — with a non-compete clause thrown in at the very end. No more golf apps, ever.</p>
<p>He said no.</p>
<h2><strong>The dark winter</strong></h2>
<p>The high lasted about two weeks. Then Eric fell into what he describes as a “dark, dark hole.” He’d turned down real money — not life-changing, but a cushion for a family with a newborn — and the buyers had made their position clear: “We’re going to build what you’ve built. We have more distribution, more brand power. We have a full-time developer. You’re teaching and you have a newborn. It’s over for you.”</p>
<p>October hit. Golf season ended. Revenue dropped. Trials dropped. Subscriptions dropped. Everything went backwards. Eric did his taxes and calculated that at his current profit rate, it would take 75 years to catch up to the $75,000 he’d walked away from.</p>
<p>He tried to sell the app to someone else. Nobody responded. He emailed Curtis Herbert, who talked him off the ledge.</p>
<h2><strong>Spend a dollar, make 4</strong></h2>
<p>The next spring, Eric decided to try Meta ads. He’d learned from Sub Club and other podcasts that you need creative inventory and enough budget for the algorithm to learn. He paid some content creators, set a $300/day budget, and at the last second threw in a scrappy screen recording with his own voiceover — raw, unpolished, zero production cost.</p>
<p>That last-second addition outperformed everything else. Cost per install: $0.80. His 30-day LTV per download: over $4. “Spend a dollar, make four,” he says. He told his wife he was going to get a line of credit at the bank. She grabbed him by the collar.</p>
<p>The reason it worked wasn’t sophisticated targeting. The creative itself did the filtering. It showed the app doing the thing that strategy-obsessed golfers already cared about, in language they already used. People who weren’t in that niche scrolled past. People who were stopped in their tracks.</p>
<p>By the end of 2024 — his first full year — Eric had outrun the $75K offer. Not just in revenue, but in profit and what he paid himself. He still had the app. He still had the momentum.</p>
<h2><strong>$1M as a teacher with a side project</strong></h2>
<p>In early 2026, Shot Pattern crossed $1 million in total sales. Eric is still a full-time high school teacher. He teaches intro to business, personal finance, and next year, sports marketing. He recently showed his students a viral video he’d made for the app and explained his target customer profile: “They look like me, they dress like me, they probably think like me, they’re bald like me.”</p>
<p>A student raised his hand, patted his belly, and said he was working on looking more like Eric’s target customer. “He didn’t mean anything by it,” Eric says, “but it came off as quite the dis.”</p>
<p>The competition that threatened to crush him never built the feature. Eric hired his first contractor — not a developer, but a general business operator to handle email automation, paywalls, and experiments. He’s still the sole developer. He still loves that part.</p>
<p>“I still think it’s one of the coolest things,” he says. “To sit down and solve this puzzle with your mind and then show it to people and give them value in exchange for dollars and have it feel like a win for everybody.”</p>
<p>In <a href="https://www.youtube.com/watch?v=wXDqhhSNJQ4">the full episode</a>, Eric also talks about his first failed meditation app for athletes, what he learned from working with UI/UX students, how he handles the emotional weight of seasonal revenue swings, and why he recommends the book The Only Way to Win by Dr. James Loehr to every high performer he meets.</p>
<h2>Guest links:</h2>
<p>•<a href="https://www.linkedin.com/in/eric-duffett/">Eric Duffett on LinkedIn</a></p>
<p>•<a href="https://twitter.com/shotpattern">Shot Pattern on social media</a> (all platforms)</p>
<p>•<a href="https://apps.apple.com/app/shot-pattern-golf-gps/id6449382487">Shot Pattern app</a></p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Why we removed “always decline” as a refund preference]]></title>
      <link>https://www.revenuecat.com/blog/company/why-we-removed-always-decline-refund-preference</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/company/why-we-removed-always-decline-refund-preference</guid>
      <pubDate>Wed, 17 Jun 2026 13:06:54 GMT</pubDate>
      <dc:creator><![CDATA[Perttu Lähteenlahti]]></dc:creator>
      <category><![CDATA[Company]]></category>
      <description><![CDATA["Always decline" refunds is gone from RevenueCat. Here's why it was working against you.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/314ee87248822f9029b34ad5f87ea91596472671-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>If you’ve recently tried to set your refund preference to “Always decline” in the RevenueCat dashboard, you may have noticed the option is gone. We removed it because blanket declines lose their effectiveness over time and can push users into chargebacks – the worst outcome for everyone. If “Always decline” was your existing setting, it’s still active. Below: the full reasoning and how it affects you.</p>
<aside class="tip"><strong>Update</strong><p>RevenueCat now includes <strong>Refund Control</strong>, a dedicated dashboard for configuring refund preferences across different customer segments. Instead of relying on a single global preference, you can define policies for different audiences and let Apple and Google receive the most appropriate recommendation for each refund request. <a href="https://www.revenuecat.com/docs/customers/refund-control">See our documentation for details</a>.</p></aside>
<h2>What changed</h2>
<p>The RevenueCat dashboard no longer allows users to select “Always decline” as a global refund preference.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/2d998fec0b8b9cf956226259c5122b1c1930e75d-1806x790.png" alt="Always prefer declining refunds is no longer an option in the Handling of refund requests section."/><figcaption>‘Always prefer declining refunds’ is no longer an option in the Handling of refund requests section.</figcaption></figure>
<p>The available options now are:</p>
<ul>
<li><strong>Let Apple decide:</strong> Neutral, no preference expressed</li>
<li><strong>Grant in full:</strong> You prefer the refund is approved</li>
<li><strong>Grant prorated:</strong> You prefer a partial refund based on consumption</li>
</ul>
<p>We didn’t unilaterally change settings for developers who already had “Always decline” enabled — it’s still active if that was your setting. If you do switch it off, you’ll see a confirmation prompt letting you know you won’t be able to re-enable it. That’s your call to make, but the case for keeping it on is getting weaker.</p>
<h2>Why “always decline” was removed</h2>
<p>This comes down to how Apple’s refund system actually works:</p>
<p><strong>Refund preferences are signals, not instructions.</strong> When you set a refund preference and send it to Apple via the <a href="https://developer.apple.com/documentation/appstoreserverapi">App Store Server API</a>, you’re not making a binding decision. <a href="https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest">Apple uses your input as one of many factors when evaluating a refund request</a>. The <code>refundPreference</code> field in a <code>ConsumptionRequest</code> is advisory by design.</p>
<p><strong>Blanket declines erode their own effectiveness.</strong> When a preference is always set to “decline” regardless of context — whether the user barely touched the app or encountered a genuine bug that made it unusable — Apple’s systems start to discount it. A preference that clearly doesn’t account for the specifics of a transaction carries less weight over time. The signal becomes noise.</p>
<p><strong>Rejecting valid refund requests can backfire.</strong> When a legitimate refund request is declined, some users go straight to their bank and issue a chargeback instead. Chargebacks are worse for developers, as a high chargeback rate puts your developer account at risk of being suspended by Apple. A blanket “always decline” policy can inadvertently push users toward the worse outcome for everyone.</p>
<h2>What to do right now</h2>
<p>If you want more control over refund recommendations, use Refund Control in the RevenueCat dashboard.</p>
<p>Refund Control lets you create multiple policies based on customer audiences, such as platform, country, first purchase date, or any custom audience you define. Each policy can express your preferred refund outcome:</p>
<ul>
<li>Decline</li>
<li>Grant</li>
<li>Neutral (no preference)</li>
</ul>
<p>RevenueCat sends the matching preference to Apple through the Consumption Request API and to Google through the Refund Review API. In both cases, your preference is advisory only—the store always makes the final refund decision.</p>
<p>This approach encourages nuanced refund policies instead of blanket &quot;decline everything&quot; behavior, which app stores have made clear they discourage. It also lets you reserve decline recommendations for cases where they're actually appropriate, making the signal more meaningful over time</p>
<h2>The bottom line</h2>
<p>“Always decline” was a blunt instrument, and it was becoming less effective over time. We’ve seen enough cases of it backfiring that we felt it was important to be straight with you about this. We know it’s not what everyone wanted to hear. At the end of the day, a setting that doesn’t do what you think it does isn’t worth the risk.</p>
<p><strong>See also:</strong></p>
<ul>
<li><a href="https://www.revenuecat.com/docs/refunds">How to handle refunds in RevenueCat</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Launch Party June ’26: The New Features You Should Know]]></title>
      <link>https://www.revenuecat.com/blog/engineering/launch-party-june-26-the-new-features-you-should-know</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/launch-party-june-26-the-new-features-you-should-know</guid>
      <pubDate>Fri, 12 Jun 2026 00:46:12 GMT</pubDate>
      <dc:creator><![CDATA[Francie Fernandes]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[A recap (and videos) of our latest launch party demos.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/f267ce415f3792d24d538314b35272ae7810485a-1642x872.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>On June 5, 2026 we hosted special edition of our <a href="https://app.livestorm.co/revenuecat/live-revenuecat-demo?type=detailed">bi-weekly office hours</a> featuring live demos and Q&amp;A around our recent features launches. It was hosted by the Product Managers who built and shipped the features, giving you the chance to discuss your questions with the team live.</p>
<p>If you couldn’t join live, here’s a summary of what we covered and clips of the demos.</p>
<h2>Paywalls</h2>
<h3>AI Editor</h3>
<p><a href="https://www.youtube.com/watch?v=Rdqbfgk3N5s">Watch on YouTube</a></p>
<p>The AI Editor is a conversational AI agent built directly into the RevenueCat Paywall Editor that lets you create, edit, and refine paywalls using natural language prompts.</p>
<p>This feature is ideal for rapidly generating a production-ready paywall from scratch, adjusting visual design like colors and typography, or updating existing templates without manual tweaking. You chat with the AI (for example, “Make a paywall for a book tracking app targeting the BookTok community”) and it automatically adjusts the copy, layout, and imagery. It can also accept a <code>design.md</code> file to match your brand guidelines or a screenshot for inspiration.</p>
<p>To set it up, go to Paywalls in the RevenueCat dashboard, click <strong>Create paywall</strong>, and select <strong>Generate with AI</strong>, or open an existing paywall and use the <strong>AI Editor</strong> tab in the left sidebar. <a href="https://www.revenuecat.com/blog/company/paywalls-ai-editor/?_gl=1*awxc43*_up*MQ..*_ga*NjI4MTgwNDM0LjE3ODEyMjUyMTM.*_ga_0MLNVKXFGB*czE3ODEyMjUyMTMkbzEkZzAkdDE3ODEyMjUyMTMkajYwJGwwJGg1MTcxNzAwNjU.">Check out the docs here.</a></p>
<h3>Paywall Rules</h3>
<p><a href="https://www.youtube.com/watch?v=o6pfogF3ebw">Watch on YouTube</a></p>
<p><a href="https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/rules">Paywall Rules</a> allow you to customize the visibility of paywall components based on both preset and Custom Variable based rules, enabling you to customize a single paywall to support multiple scenarios.</p>
<p>One example is using Paywall Rules to show a trial timeline only when a trial is available, or to display a different package (like family sharing vs. individual) based on a custom variable.</p>
<p>Rules are evaluated at runtime. You define conditions such as <code>offer.intro</code>, <code>package.identifier</code>, or custom variables, and set components to be visible or hidden when those conditions are met.</p>
<p>To set this up, open the <strong>Paywall logic</strong> tab in the Paywall Editor, create a new rule based on your desired condition, and then set the visibility of your paywall components for that rule. Check out <a href="https://www.revenuecat.com/blog/engineering/announcing-paywall-rules-show-or-hide-paywall-components/">this blog post</a> for more info.</p>
<h2>Funnels</h2>
<h3>Funnels Builder</h3>
<p><a href="https://www.youtube.com/watch?v=B5ayNuAhOOc">Watch on YouTube</a></p>
<p>Funnels allow you to build customizable, hosted web onboarding experiences that can be designed remotely from the RevenueCat dashboard and shipped by anyone on your team.</p>
<p>Use cases for funnels include web-to-app acquisition funnels for ad campaigns or influencers, surveying users before checkout, and capturing web conversions with lower friction. You build multi-step web flows using the Funnels editor, which is similar to the Paywalls editor. You can add screens, branching logic, survey questions, and a checkout step. It currently supports Stripe and Paddle for payments.</p>
<p>To set it up, configure a payment provider in RevenueCat, create a Funnel in the dashboard, design your steps and branching logic, and deploy the generated URL. <a href="https://www.revenuecat.com/docs/tools/funnels/creating-funnels">Check out the docs here.</a></p>
<h3>A/B Testing</h3>
<p>Testing within Funnels enables you to run experiments directly within your web Funnels to optimize conversion.</p>
<p>This feature is used for testing different price points, paywall designs, or onboarding survey flows to see which yields the highest conversion rate on the web. In the Funnel Editor, you can add an “Experiment” branch that splits traffic between different paths (e.g., a high-price paywall vs. a low-price paywall) and track the results in the funnel analytics.</p>
<p>To set it up, add an Experiment node in your funnel flow and connect it to your variant screens or paywalls. <a href="https://www.revenuecat.com/docs/tools/funnels/experimenting-with-funnels">Check out the docs here.</a></p>
<h2>Web</h2>
<p><a href="https://www.youtube.com/watch?v=Q_xAWmOzsYg">Watch on YouTube</a></p>
<h3>One-Tap Purchases via Express Checkout</h3>
<p>One-Tap Purchases via Express Checkout adds Apple Pay and Google Pay buttons directly to your web paywalls and funnels for frictionless purchasing.</p>
<p>This maximizes web conversion rates by allowing users to skip the traditional credit card form and check out with a single tap. The Express Checkout component surfaces Apple Pay or Google Pay (if supported by the user’s device and browser) and processes the payment through Stripe.</p>
<p>To set it up, add the “Express checkout buttons” component to your paywall in the Paywall Editor or Funnel Editor. <a href="https://www.revenuecat.com/docs/tools/paywalls/creating-paywalls/components?_gl=1*1q9msjx*_up*MQ..*_ga*NjI4MTgwNDM0LjE3ODEyMjUyMTM.*_ga_0MLNVKXFGB*czE3ODEyMjUyMTMkbzEkZzAkdDE3ODEyMjUyMTMkajYwJGwwJGg1MTcxNzAwNjU.#express-checkout">See the docs here.</a></p>
<h3>Stripe Billing &amp; Stripe Managed Payments</h3>
<p>RevenueCat supports both Stripe Billing and Stripe Managed Payments, a Merchant of Record solution where Stripe handles tax compliance, collection, and remittance.</p>
<p>A merchant of record solution is ideal for selling subscriptions on the web while offloading the complexity of global sales tax, VAT, and GST to Stripe. RevenueCat creates a Stripe Checkout session, and for eligible products, Stripe acts as the Merchant of Record, processes the payment, handles taxes, while RevenueCat tracks the transaction and unlocks entitlements.</p>
<p>To set it up, connect your Stripe account via the RevenueCat Stripe App, create a Stripe Web Config in RevenueCat, and toggle on “Use Managed Payments when available”. <a href="https://www.revenuecat.com/docs/web/integrations/stripe?_gl=1*o7m74e*_up*MQ..*_ga*NjI4MTgwNDM0LjE3ODEyMjUyMTM.*_ga_0MLNVKXFGB*czE3ODEyMjUyMTMkbzEkZzAkdDE3ODEyMjUyMTMkajYwJGwwJGg1MTcxNzAwNjU.">See the docs.</a></p>
<h3>Discounts and Discount Codes</h3>
<p>This is a flexible system to apply percentage-based discounts to products in RevenueCat Web Billing using shareable, customer-facing discount codes.</p>
<p>Use cases include running promotional campaigns like Black Friday, attributing sales to influencers via unique codes, or offering win-back discounts. You create a discount (e.g., 50% off for 3 months) and generate codes (e.g., BLACKFRIDAY50). Customers enter the code at checkout, or it can be auto-applied via a URL parameter on a Web Purchase Link.</p>
<p>To set it up, navigate to <strong>Product catalog &gt; Web discounts</strong> in the dashboard, create a discount, define its rules and codes, and enable discount codes on your Web Purchase Links. <a href="https://www.revenuecat.com/docs/web/web-billing/discounts?_gl=1*awxc43*_up*MQ..*_ga*NjI4MTgwNDM0LjE3ODEyMjUyMTM.*_ga_0MLNVKXFGB*czE3ODEyMjUyMTMkbzEkZzAkdDE3ODEyMjUyMTMkajYwJGwwJGg1MTcxNzAwNjU.">Check out the docs here.</a></p>
<h2>Experiments</h2>
<h3>pLTV Winner, Credible Intervals, Astra Analysis, Winner Rollout</h3>
<p><a href="https://www.youtube.com/watch?v=eeaffHtHlbE">Watch on YouTube</a></p>
<p>This suite of features enhances Experiments with advanced statistical tools: predicted LTV (pLTV) winner predictions, credible intervals for metrics, AI-driven analysis via Rico (Astra), and one-click winner rollouts.</p>
<p>These tools help you make confident decisions on A/B tests by understanding not just initial conversion, but long-term value, and quickly deploying the winning variant. RevenueCat calculates the Chance to Win and 95% credible intervals for conversion metrics. Once sufficient data is gathered, it predicts the pLTV winner. You can ask Rico to analyze the nuances of the test, and use the “Rollout” option to set the winner as the default offering or create a targeting rule.</p>
<p>These features automatically populate in the Results tab of your running Experiments.</p>
<h2>AI Tools</h2>
<h3>Rico</h3>
<p><a href="https://www.youtube.com/watch?v=IRKJqWe__XQ">Watch on YouTube</a></p>
<p>Rico is RevenueCat’s AI-powered app growth advisor built into the dashboard and available in Slack.</p>
<p>It is designed for diagnosing revenue drops, analyzing experiment results, benchmarking against industry data, or answering SDK integration questions using plain language. Rico has access to your app’s data, charts, experiments, RevenueCat documentation, and industry benchmarks. You ask it a question, and it reasons through the data to provide an answer.</p>
<p>To set it up, use the chat interface in the dashboard overview. To use it in Slack, go to Account Settings, select the Rico tab, and connect it to your workspace.</p>
<p><a href="https://www.revenuecat.com/blog/company/rico-app-growth-advisor/?_gl=1*o7m74e*_up*MQ..*_ga*NjI4MTgwNDM0LjE3ODEyMjUyMTM.*_ga_0MLNVKXFGB*czE3ODEyMjUyMTMkbzEkZzAkdDE3ODEyMjUyMTMkajYwJGwwJGg1MTcxNzAwNjU.">Read more on Rico in this blog post.</a></p>
<h3>AI Toolkit</h3>
<p><a href="https://www.youtube.com/watch?v=Nz1xLbVgRQ4">Watch on YouTube</a></p>
<p>RevenueCat offers a comprehensive AI toolkit to help AI agents set up RevenueCat, integrate it into apps, and access your revenue data — all directly from your AI coding assistant.</p>
<p><a href="http://revenuecat.com/docs/tools/overview">Explore the docs.</a></p>
<h2>Charts &amp; Dashboard</h2>
<h3>Benchmarks</h3>
<p><a href="https://www.youtube.com/watch?v=jn1NtBKww64">Watch on YouTube</a></p>
<p>Benchmarks allow you to compare your app’s key metrics like conversion, churn, and realized LTV against other apps in the same store and category.</p>
<p>This helps identify growth opportunities, set meaningful goals, and see how your app stacks up against peers in metrics like trial conversion or refund rates. RevenueCat calculates benchmark percentiles using anonymized data from apps in the same category over the last 12 months. Your app’s performance is shown as a percentile (e.g., 90th percentile).</p>
<p>Benchmarks are automatically available in the dashboard under the Benchmarks tab for eligible apps.</p>
<p><a href="https://www.revenuecat.com/docs/dashboard-and-metrics/benchmarks?_gl=1*nnzjuy*_up*MQ..*_ga*NjI4MTgwNDM0LjE3ODEyMjUyMTM.*_ga_0MLNVKXFGB*czE3ODEyMjUyMTMkbzEkZzAkdDE3ODEyMjUyMTMkajYwJGwwJGg1MTcxNzAwNjU.">See the docs.</a></p>
<h3>In-App Ad Revenue in Charts</h3>
<p><a href="https://www.youtube.com/watch?v=nVQTdLVnZbI">Watch on YouTube</a></p>
<p>In-App Ad Revenue in Charts tracks ad impressions, clicks, and revenue alongside your subscription data for a unified view of your app’s hybrid monetization.</p>
<p>This is essential for calculating accurate Realized LTV that includes both ads and subscriptions, and understanding how ad revenue varies by segment. By hooking into your ad mediation platform’s (like AdMob or AppLovin) impression-level revenue data (ILRD) callbacks, RevenueCat tracks ad events and displays them in dedicated Ad Charts and the main Revenue mix chart.</p>
<p>To set it up, opt-in to the beta in the Ads page of the dashboard, and integrate the AdMob Adapter SDK or manually call the <code>AdTracker</code> methods from your ad SDK callbacks. <a href="https://www.revenuecat.com/docs/dashboard-and-metrics/charts/ads">See the docs.</a></p>
<h2>Store Updates</h2>
<h3>Apple 12-Month Commitments</h3>
<p><a href="https://www.youtube.com/watch?v=mXZhEWSvazE">Watch on YouTube</a></p>
<p>This feature supports Apple’s new subscription option that bills customers monthly but requires a 12-month commitment.</p>
<p>It is highly effective for lowering the upfront price barrier for annual subscriptions in price-sensitive regions while maintaining long-term retention. Apple models this as a billing plan under an annual product. RevenueCat exposes the monthly commitment plan as a separate product ID with a <code>:monthly </code>suffix (e.g., <code>com.myapp.product:monthly</code>).</p>
<p>To set it up, configure the billing plan in App Store Connect. In RevenueCat, create a second product and check the “12 months commitment” option. Ensure you are using StoreKit 2 and iOS 26.4+.</p>
<p><a href="https://www.revenuecat.com/blog/engineering/monthly-subscription-12-month-commitment/">More on the blog.</a></p>
<h2>Join our next office hours</h2>
<p>Wish you were there? Don’t worry, you can find our team live every other <a href="https://app.livestorm.co/revenuecat/live-revenuecat-demo?type=detailed">Friday for Office Hours.</a></p>
<p>We gather members of the RevenueCat team, our community, and anyone curious about RC or consumer-software monetisation as a whole for a fast-moving, fully interactive Q&amp;A and platform demo session.</p>
<p>We plan to take over one of these session with a launch party at least once a quarter, so stay tuned for the next one</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Introducing the RevenueCat Codegen Gradle Plugin: type safe entitlements and offerings on Android]]></title>
      <link>https://www.revenuecat.com/blog/engineering/android-codegen</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/android-codegen</guid>
      <pubDate>Thu, 11 Jun 2026 16:00:00 GMT</pubDate>
      <dc:creator><![CDATA[Jaewoong Eum]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[In this article, you'll explore RevenueCat's Codegen Gradle plugin, which generates product data code automatically.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/a89d85ffd5ba9cb817d6601fa43526bd8e1f8baa-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Every RevenueCat integration shares the same quiet liability: the string keys. Your entitlements, offerings, and packages live in the RevenueCat dashboard, and your Kotlin code reaches for them with raw strings like <code>entitlements[&quot;premium_access&quot;]</code>. The compiler cannot verify those strings, the IDE cannot autocomplete them, and a single typo ships as a runtime bug instead of failing the build. The new <a href="https://github.com/RevenueCat/purchases-android/tree/main/codegen">RevenueCat Codegen Gradle Plugin</a> closes this gap by talking to the RevenueCat API at build time and generating type-safe Kotlin accessors for everything you’ve configured in the dashboard.</p>
<p>In this article, you’ll explore what the plugin generates and why, how its two Gradle tasks fetch and cache your project schema, how to set it up with a version catalog, how naming styles turn dashboard lookup keys into Kotlin identifiers, how caching and offline mode keep builds working without a network, and the trade-offs to weigh before adopting it.</p>
<h2><strong>The fundamental problem: Dashboard keys as raw strings</strong></h2>
<p>Consider the typical code for gating a premium feature and resolving a package to purchase:</p>
<pre><code class="language-kotlin">val isPremium = customerInfo.entitlements[&quot;premium_access&quot;]?.isActive == true
val offering = offerings.getOffering(&quot;perplexity&quot;)
val monthly = offering?.getPackage(&quot;\$rc_monthly&quot;)</code></pre>
<p>This code compiles whether or not <code>&quot;premium_access&quot;</code> exists in your project. If someone renames the entitlement in the dashboard, or a teammate types <code>&quot;premium_acess&quot;</code> in a new screen, nothing fails until a user hits that code path: the entitlement lookup silently returns <code>null</code>, and <code>getPackage</code> throws at runtime. The usual mitigation is a hand maintained constants file, but that file drifts out of sync with the dashboard the moment anyone forgets to update it.</p>
<p>With the codegen plugin applied, the same logic becomes:</p>
<pre><code class="language-kotlin">val isPremium = customerInfo.isPremiumAccessActive
val offering = offerings.perplexity
val monthly = offering?.perplexityMonthly</code></pre>
<p>The difference is more than aesthetics:</p>
<ul>
<li><strong>IDE autocomplete</strong>: every entitlement, offering, and package from your dashboard surfaces as a typed property, so you discover them by typing a dot instead of switching to a browser tab.</li>
<li><strong>Compile time safety</strong>: a typo or a removed entitlement becomes a build error, not a runtime <code>null</code>.</li>
<li><strong>No drift</strong>: the plugin fetches your latest dashboard state at build time, so there is no constants file to maintain by hand.</li>
</ul>
<h2><strong>Setting up the plugin</strong></h2>
<p>The plugin is published to Maven Central as part of the RevenueCat Purchases SDK. The plugin ID is <code>com.revenuecat.purchases.codegen</code>, and its version always matches the Purchases SDK version, so you reuse the version string you already have.</p>
<h3><strong>Step 1: Apply the plugin</strong></h3>
<p>Add it to your version catalog in <code>gradle/libs.versions.toml</code>:</p>
<pre><code class="language-kotlin">[versions]
purchases = &quot;PURCHASES_VERSION&quot;

[plugins]
revenuecat-codegen = { id = &quot;com.revenuecat.purchases.codegen&quot;, version.ref = &quot;purchases&quot; }</code></pre>
<p>Declare it in your root <code>build.gradle.kts</code> so Gradle resolves it for all subprojects:</p>
<pre><code class="language-kotlin">plugins {
    alias(libs.plugins.revenuecat.codegen) apply false
}</code></pre>
<p>Then apply it in your app module’s <code>build.gradle.kts</code>:</p>
<pre><code class="language-kotlin">plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.revenuecat.codegen)
}</code></pre>
<h3><strong>Step 2: Configure the extension</strong></h3>
<p>Only three properties are required: <code>apiKey</code>, <code>projectId</code>, and <code>packageName</code>. Everything else has a default.</p>
<pre><code class="language-kotlin">revenuecat {
    apiKey.set(&quot;sk_your_v2_secret_key&quot;)
    projectId.set(&quot;proj_your_project_id&quot;)
    packageName.set(&quot;com.example.app.rc&quot;)
}</code></pre>
<p><code><strong>apiKey</strong></code> must be a v2 secret key (it starts with <code>sk_</code>) with read permissions, created in the RevenueCat dashboard under Project Settings &gt; API Keys. This key is used only at build time and is never compiled into your app binary. You’ll see how to keep it out of version control in a later section.</p>
<p><code><strong>projectId</strong></code> is your project’s identifier, also found in Project Settings.</p>
<p><code><strong>packageName</strong></code> is the package the generated code lands in, and it must be a package you own, such as <code>com.myapp.rc</code>. Avoid anything under <code>com.revenuecat.*</code>. The Purchases SDK ships a consumer ProGuard rule, <code>-keep class com.revenuecat.** { *; }</code>, which prevents classes in that namespace from being shrunk or obfuscated. If your generated code lands there, R8 retains all of it in release builds even when unused, which bloats your APK for no benefit.</p>
<p>The full set of optional properties looks like this:</p>
<pre><code class="language-kotlin">import com.revenuecat.purchases.codegen.NamingStyle
import com.revenuecat.purchases.codegen.OfflineMode

revenuecat {
    apiKey.set(&quot;sk_your_v2_secret_key&quot;)
    projectId.set(&quot;proj_your_project_id&quot;)
    packageName.set(&quot;com.example.app.rc&quot;)

    cacheTtlMinutes.set(30L)
    offlineMode.set(OfflineMode.USE_CACHE_OR_SKIP)
    namingStyle.set(NamingStyle.CAMEL_CASE)

    generateEntitlements.set(true)
    generateOfferings.set(true)
    generatePackages.set(true)
    generateCustomerInfoExtensions.set(true)
}</code></pre>
<p>The four <code>generate*</code> flags each control one category of output. If you iterate <code>offering.availablePackages</code> dynamically rather than accessing packages by name, for example, set <code>generatePackages.set(false)</code> to keep the generated surface small. The caching and offline options are covered in their own section below.</p>
<h3><strong>Step 3: Build</strong></h3>
<p>Run any standard build task:</p>
<p><code>./gradlew assembleDebug</code></p>
<p>Generation runs automatically before compilation. After the first successful build, do File &gt; Sync Project with Gradle Files in Android Studio so the IDE picks up the new source directory and autocomplete starts working.</p>
<p>If you ever want to fetch or regenerate without a full build, both tasks are invokable directly:</p>
<p><code>./gradlew :app:rcFetchSchema</code>
<code>./gradlew :app:rcGenerateCode</code></p>
<h2><strong>What gets generated</strong></h2>
<p>Suppose your project has a <code>premium_access</code> entitlement and a <code>perplexity</code> offering containing the standard <code>$rc_monthly</code> and <code>$rc_annual</code> packages. The plugin generates four categories of output.</p>
<h3><strong>Entitlement ID constants</strong></h3>
<p>A single <code>RCEntitlementId</code> object holds one constant per entitlement:</p>
<pre><code class="language-kotlin">object RCEntitlementId {
    const val PREMIUM_ACCESS: String = &quot;premium_access&quot;
}</code></pre>
<p>These constants are useful at the boundaries where you still need the raw key, such as logging or analytics events, while keeping the compiler in the loop.</p>
<h3><strong>Type safe </strong><code><strong>EntitlementInfos</strong></code><strong> extensions</strong></h3>
<p>For each entitlement, two extension properties land on <code>EntitlementInfos</code>: an accessor that returns the <code>EntitlementInfo</code> or <code>null</code>, and a boolean shortcut for the common <code>isActive</code> check.</p>
<pre><code class="language-kotlin">val EntitlementInfos.premiumAccess: EntitlementInfo?
    get() = this[&quot;premium_access&quot;]

val EntitlementInfos.isPremiumAccessActive: Boolean
    get() = this[&quot;premium_access&quot;]?.isActive == true</code></pre>
<h3><strong>Convenience </strong><code><strong>CustomerInfo</strong></code><strong> extensions</strong></h3>
<p>The same active check is also generated directly on <code>CustomerInfo</code>, so gating a feature behind a single entitlement skips the <code>entitlements</code> lookup entirely:</p>
<pre><code class="language-kotlin">val CustomerInfo.isPremiumAccessActive: Boolean
    get() = this.entitlements[&quot;premium_access&quot;]?.isActive == true</code></pre>
<h3><strong>Offering and package accessors</strong></h3>
<p>Each offering gets a constant in <code>RCOfferingId</code> and a typed accessor on <code>Offerings</code> that wraps <code>getOffering()</code>:</p>
<pre><code class="language-kotlin">object RCOfferingId {
    const val PERPLEXITY: String = &quot;perplexity&quot;
}

val Offerings.perplexity: Offering?
    get() = this.getOffering(&quot;perplexity&quot;)</code></pre>
<p>Packages need one extra design decision. Multiple offerings commonly share the same package lookup key, since <code>$rc_monthly</code> exists in nearly every offering. Generating a bare <code>monthly</code> extension on <code>Offering</code> would collide the moment a second offering defines the same package. The plugin resolves this by prefixing each package property with its offering name and scoping the ID constants into a per offering object:</p>
<pre><code class="language-kotlin">object RCPerplexityPackageId {
    const val MONTHLY: String = &quot;${'$'}rc_monthly&quot;
    const val ANNUAL: String = &quot;${'$'}rc_annual&quot;
}

val Offering.perplexityMonthly: Package?
    get() = this.getPackage(&quot;${'$'}rc_monthly&quot;)</code></pre>
<p>The <code>${'$'}</code> sequence might catch your eye. Because <code>$</code> begins a string template in Kotlin, KotlinPoet escapes the literal dollar sign in <code>$rc_monthly</code> this way so the generated file always compiles. At runtime the string is exactly <code>$rc_monthly</code>.</p>
<p>Every generated property also carries a KDoc comment with the display name from your dashboard and a reminder that the code reflects the dashboard at build time, so the documentation popup in the IDE tells you what each key actually is.</p>
<h2><strong>Naming styles: From lookup keys to Kotlin identifiers</strong></h2>
<p>Dashboard lookup keys are arbitrary strings, so the plugin runs each one through a pipeline before it becomes an identifier: strip the <code>$rc_</code> prefix, apply the configured naming style, replace characters that are invalid in identifiers, prefix an underscore when the key starts with a digit, and escape Kotlin reserved words with backticks. The result always compiles, even if someone names an entitlement <code>when</code> or <code>2024_promo</code> in the dashboard.</p>
<p>The <code>namingStyle</code> option controls the middle step:</p>
<table>
<thead><tr>
<th><p>Lookup key</p></th>
<th><p><code>CAMEL_CASE</code> (default)</p></th>
<th><p><code>SNAKE_CASE</code></p></th>
<th><p><code>AS_IS</code></p></th>
</tr></thead>
<tbody>
<tr>
<td><p><code>premium_access</code></p></td>
<td><p><code>premiumAccess</code></p></td>
<td><p><code>premium_access</code></p></td>
<td><p><code>premium_access</code></p></td>
</tr>
<tr>
<td><p><code>ProPhoto</code></p></td>
<td><p><code>prophoto</code></p></td>
<td><p><code>pro_photo</code></p></td>
<td><p><code>ProPhoto</code></p></td>
</tr>
<tr>
<td><p><code>$rc_monthly</code></p></td>
<td><p><code>monthly</code></p></td>
<td><p><code>monthly</code></p></td>
<td><p><code>monthly</code></p></td>
</tr>
</tbody>
</table>
<p><code>CAMEL_CASE</code> fits most Kotlin codebases because it follows the standard property naming convention. <code>AS_IS</code> preserves the key exactly after prefix stripping, which is useful when your lookup keys are already well formed identifiers and you want a one to one mapping. The constants inside ID objects like <code>RCEntitlementId</code> are always <code>UPPER_SNAKE_CASE</code> regardless of the style, because constants follow their own convention in Kotlin.</p>
<h2><strong>Caching and offline mode: Builds that don’t depend on the network</strong></h2>
<p>A Gradle plugin that hits the network on every build would be a hard sell, so the fetch step is built around a local cache. <code>rcFetchSchema</code> writes the fetched schema and a timestamp to <code>build/revenuecat/cache/revenuecat-schema.json</code>. On the next build, it checks the file’s age against <code>cacheTtlMinutes</code> (default 30) and skips the network call entirely if the cache is still fresh. Setting the TTL to <code>0</code> forces a fetch on every build.</p>
<p>When a fetch does run and your project has many offerings, the client paces itself: it follows the API’s pagination, waits 500 milliseconds between requests, and retries up to five times with exponential backoff when it hits a rate limit response. This makes the first build slower on large projects, but every subsequent build reads from the cache.</p>
<p>The interesting question is what happens when the fetch fails: no network on a flight, a connection timeout, or a key that was revoked. The <code>offlineMode</code> option offers two behaviors:</p>
<ul>
<li><code><strong>USE_CACHE_OR_SKIP</strong></code> (default): if a stale cache exists, it is used and a warning logs the cache age. If no cache exists at all, generation is skipped with a log message and the build continues without generated sources. Local builds keep working with no intervention.</li>
<li><code><strong>FAIL</strong></code>: the build fails immediately with a <code>GradleException</code> describing the error. This is the right choice for a release pipeline where you need a hard guarantee that the generated code reflects the latest dashboard state.</li>
</ul>
<p>A practical pattern applies each mode based on the environment:</p>
<pre><code class="language-kotlin">import com.revenuecat.purchases.codegen.OfflineMode

val isCI = providers.environmentVariable(&quot;CI&quot;).isPresent

revenuecat {
    apiKey.set(providers.environmentVariable(&quot;REVENUECAT_API_KEY&quot;).getOrElse(&quot;&quot;))
    projectId.set(providers.environmentVariable(&quot;REVENUECAT_PROJECT_ID&quot;).getOrElse(&quot;&quot;))
    packageName.set(&quot;com.example.app.rc&quot;)
    offlineMode.set(if (isCI) OfflineMode.FAIL else OfflineMode.USE_CACHE_OR_SKIP)
}</code></pre>
<p>With this setup, a local build falls back to the cache when the network is unavailable, while CI refuses to ship a build whose generated code might be stale.</p>
<p>Note that the fallback applies to fetch failures, not to missing configuration. An empty <code>apiKey</code> fails the build immediately with “revenuecat.apiKey must be configured in your build script.” regardless of the offline mode, so every developer needs the key configured even when a cache is present.</p>
<p>One more consequence of <code>USE_CACHE_OR_SKIP</code> is worth calling out: if your very first fetch fails, generation is skipped and references to the generated code fail to compile with unresolved symbols. If you hit “No RevenueCat schema cache found. Skipping code generation.” in the build log, temporarily set <code>offlineMode.set(OfflineMode.FAIL)</code> to surface the underlying error, which is usually a wrong <code>apiKey</code> or <code>projectId</code>.</p>
<h3><strong>Refreshing after dashboard changes</strong></h3>
<p>The generated code reflects your dashboard at the time of the last fetch. After you add or rename an entitlement, a normal build picks the change up once the TTL expires. To pick it up immediately, wipe the cache and rerun the tasks:</p>
<pre><code class="language-kotlin">rm -rf app\/build\/revenuecat\/cache
.\/gradlew :app:rcFetchSchema :app:rcGenerateCode</code></pre>
<p>Then sync the project in Android Studio so the IDE reindexes the regenerated sources.</p>
<h2><strong>How it works: Two Gradle tasks and a schema cache</strong></h2>
<p>The plugin registers a <code>revenuecat { }</code> extension and two Gradle tasks under the <code>revenuecat</code> group:</p>
<ol>
<li><code><strong>rcFetchSchema</strong></code> calls the RevenueCat API v2 (<code>/projects/{projectId}/entitlements</code>, <code>/projects/{projectId}/offerings</code>, and <code>/projects/{projectId}/offerings/{offeringId}/packages</code>), follows pagination, and writes the result to a JSON cache at <code>build/revenuecat/cache/revenuecat-schema.json</code> along with a timestamp.</li>
<li><code><strong>rcGenerateCode</strong></code> reads that cache and generates Kotlin source files into <code>build/generated/revenuecat/kotlin/</code> using KSP.</li>
</ol>
<p>You never invoke either task by hand during normal development. The plugin wires <code>rcGenerateCode</code> into your compile tasks, so any standard build runs generation first.</p>
<h2><strong>Keeping the secret key out of version control</strong></h2>
<p>The <code>apiKey</code> is a v2 secret key, so even though it never reaches your app binary, it should not be committed in <code>build.gradle.kts</code>. The generated files contain only plain lookup key strings like <code>&quot;premium_access&quot;</code>, never the key itself, but the build script is checked in. For local development, read the key from <code>local.properties</code>, which stays out of version control:</p>
<pre><code class="language-kotlin">val localProps = java.util.Properties().apply {
    rootProject.file(&quot;local.properties&quot;).takeIf { it.exists() }?.inputStream()?.use { load(it) }
}

revenuecat {
    apiKey.set(localProps.getProperty(&quot;REVENUECAT_API_KEY&quot;, &quot;&quot;))
    projectId.set(localProps.getProperty(&quot;REVENUECAT_PROJECT_ID&quot;, &quot;&quot;))
    packageName.set(&quot;com.example.app.rc&quot;)
}</code></pre>
<p>Then add the values to <code>local.properties</code>:</p>
<pre><code class="language-text">REVENUECAT_API_KEY=sk_your_v2_secret_key
REVENUECAT_PROJECT_ID=proj_your_project_id
</code></pre>
<p>For CI, inject both values as environment variables, as shown in the offline mode example above.</p>
<h2><strong>Trade offs: When generated accessors fit and when they don’t</strong></h2>
<p>The plugin fits best with stable identifiers. Entitlement IDs rarely change once set, and turning every entitlement check into a compile time verified property removes a whole class of bugs. Offerings and packages benefit the same way when their IDs are stable, especially in a large codebase where autocomplete and rename refactoring matter.</p>
<p>For highly dynamic offerings, keep one trade off in mind. The generated code is a snapshot of your dashboard at build time. Adding a new package or renaming an offering in the dashboard requires a new build and a new app release before the generated accessors reflect it. If you iterate <code>offering.availablePackages</code> at runtime instead, an already shipped app picks up new packages without a release, because the package list comes from the RevenueCat backend at runtime.</p>
<p>In practice this matters less than it sounds. Adding a new package that you intend to reference by name in code requires writing new code anyway, which means a new release regardless. But if you drive your paywall presentation entirely from the dashboard without touching code, the runtime API keeps that flexibility, and you can disable <code>generatePackages</code> while keeping the entitlement and offering accessors. The two approaches compose: use the generated properties where identifiers are stable, and the dynamic API where the dashboard is the source of truth.</p>
<h2><strong>Conclusion</strong></h2>
<p>In this article, you’ve explored the <a href="https://github.com/RevenueCat/purchases-android/tree/main/codegen">RevenueCat Codegen Gradle Plugin:</a> the raw string problem it removes, the <code>rcFetchSchema</code> and <code>rcGenerateCode</code> tasks that fetch your dashboard schema and generate typed Kotlin accessors, the setup from version catalog to first build, the naming pipeline that turns lookup keys into identifiers that always compile, and the caching and offline modes that keep builds fast and network independent. If your entitlement checks are still string lookups, applying the plugin turns the next typo into a build error instead of a support ticket.</p>
<p>As always, happy coding!</p>
<p>— <a href="https://github.com/skydoves/">Jaewoong</a> (skydoves)</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[How to build a reactivation strategy that actually works]]></title>
      <link>https://www.revenuecat.com/blog/growth/app-reactivation-strategy-how-to</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/app-reactivation-strategy-how-to</guid>
      <pubDate>Thu, 11 Jun 2026 14:00:00 GMT</pubDate>
      <dc:creator><![CDATA[Alice Muir Kocourková]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Most apps treat cancellation as the end of the relationship. It's actually step zero of reactivation.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/f050222e4c88f1ba36e4cd8f0eca716cb0cc746f-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>A lot of subscription apps either make the mistake of treating churn as the end point and never bother to follow up with these users or they spam them with discounts, believing this is the only strategy that works to bring them back.</p>
<p>Churned users are actually one of the most valuable segments you have. They’ve already crossed the hardest threshold: they trusted you enough to pay. And yet, most apps either ignore them completely or hit them with a random, out-of-context, discount three months later. Reactivation is a whole strategy in itself, not just a campaign. If you build it properly, it becomes one of the highest ROI levers in your entire growth model.</p>
<p>According to RevenueCat’s <a href="https://www.revenuecat.com/state-of-subscription-apps/">State of Subscription Apps 2026 Report</a>:</p>
<ul>
<li>Monthly subscribers come back at ~18–24% within a year</li>
<li>Even weekly plans see ~7–10% reactivation</li>
<li>Only annual plans behave like “true churn” (~4–6%)</li>
</ul>
<p>So let’s look at some of the ways you can build a strategy around reactivation.</p>
<h2><strong>1. Start at the moment of churn: Your cancellation flow is step zero</strong></h2>
<p>Many teams treat cancellation as the last touchpoint with a user (e.g. using “Sorry to see you go”-style messaging). However, cancellation is a great opportunity for data collection segmentation and follow-up. This is where your reactivation strategy actually begins.</p>
<h3><strong>What to implement</strong></h3>
<p>A simple questionnaire that triggers on user cancellation and asks them why they no longer wish to use or pay for the app or product.</p>
<h3><strong>What to ask</strong></h3>
<p>Keep it straightforward – One clear question should suffice.</p>
<p>Not only are you looking to collect valuable insights here, you’re also tagging intent so you can follow up with users later with messaging that’s appropriate for them specifically.</p>
<p>Focus on actionable reasons like:</p>
<ul>
<li>“I don’t need it right now”</li>
<li>“Too expensive”</li>
<li>“Missing features I need”</li>
<li>“Technical issues”</li>
<li>“I didn’t get value”</li>
</ul>
<p>Each of these should map directly to a future reactivation path.</p>
<p>Note that in every app I’ve worked with, there will be a portion of users who think the product is too expensive or simply don’t want to pay. Unless your conversion is below <a href="https://www.revenuecat.com/healthscore/">benchmarks</a>, I’d take these responses with a grain of salt.</p>
<p>Make sure to also leave an “other” option open-ended so you’re collecting insights from users who may have discovered a bug or problem with the app you’re not aware of.

Once you’ve got a breakdown of the most popular answers, you can define your list of actionable reasons further.</p>
<h3><strong>What to offer</strong></h3>
<p>This is where most apps throw discounts immediately. That trains users to churn to get a better deal, and it also cheapens the value perception of your product (e.g. why did I pay $39.99 when I could have got this for $20?)</p>
<p>Instead, take each individual cancellation reason from your survey and map out a personalized reactivation plan.</p>
<p>Some examples:</p>
<ul>
<li>“Not needed right now” → Offer <strong>pause</strong> instead of cancel</li>
<li>“Too expensive” → Offer <strong>lower tier or annual reframing</strong></li>
<li>“Didn’t get value” → Offer <strong>guided setup or feature education</strong></li>
<li>“Technical issues” → Offer <strong>support escalation</strong></li>
</ul>
<p>Not every churn needs to be saved. Some should be <em>cleanly segmented for later</em>.</p>
<h3><strong>What data to capture</strong></h3>
<p>For each churned user, you want to capture the following data points:</p>
<ul>
<li>Churn reason</li>
<li>Plan type (monthly vs annual)</li>
<li>How long they were a user</li>
<li>Usage intensity before churn</li>
<li>Last meaningful action</li>
</ul>
<p>If you’re not capturing these data points, your reactivation strategy will be generic and impersonal. For example, you won’t know if this was a power user who just dropped out of the habit or a low frequency user who may need time before the use case is relevant again.</p>
<p>On iOS, there’s also a platform-level lever here that’s still relatively new and underused: <a href="https://www.revenuecat.com/blog/engineering/apple-retention-messaging-api/">Apple Retention Messaging API.</a></p>
<p>This allows developers to present a targeted message or offer directly within the App Store cancellation flow, before the subscription is fully canceled. In other words, you get one last, native touchpoint at peak intent.</p>
<p>Access and adoption are still evolving, so not every team is using it yet. But it’s worth keeping on your radar, because it fundamentally changes what’s possible at the moment of churn.</p>
<p>Used well, this can reinforce the same logic as your in-app cancellation flow:</p>
<ul>
<li>Surface a pause or downgrade option instead of pushing discounts</li>
<li>Address common churn reasons with a clear value reminder</li>
</ul>
<p>Used badly, it becomes another place to throw generic discounts and train users to game your system.</p>
<h2><strong>2. The post-churn relationship: how to stay present without being annoying</strong></h2>
<p>You want to stay relevant enough that returning feels natural, not forced.Instead of generic “come back” emails, try to focus on value reminders. Remember your product is still there to solve a user’s problem – so messaging should be centred around that, and this is where your data collection from Step 1 comes in handy.</p>
<ul>
<li>“Here’s what’s new since you left” <strong>– could target the “didn’t get value” or “technical issues” segments.</strong></li>
<li>“People like you are using X feature to achieve Y” <strong>– could target the “didn’t get value” segment.</strong></li>
<li>“Quick win you can get in 2 minutes” <strong>– could target the “didn’t get value” or “not needed right now” segments.</strong></li>
</ul>
<p>Think of it less like win-back and more like lightweight re-education. You’re rebuilding perceived value over time and just like all lifecycle messaging, the strategy needs to align exactly with the user’s goals.</p>
<h3><strong>Channel strategy</strong></h3>
<p>Email alone likely won’t carry your reactivation strategy. The strongest setups layer channels so the message shows up in the right place at the right time. Email should still do most of the heavy lifting, but it works best when reinforced with push for users who are still opted in, and paid retargeting for higher LTV churned users where the economics justify it.</p>
<p>The trap is thinking more channels means more pressure. It doesn’t. Frequency and coordination matter far more than channel count. If a user gets the same “come back” message three times in two days across different touchpoints, it feels like you’re chasing them.</p>
<p>A better approach would be along the lines of one well-timed email a few days after churn, a contextual push a week later, and a lightweight retargeting touchpoint only if the user is high value. Different messages, spaced out, and each with a clear purpose.</p>
<h2><strong>3. Timing matters more than what you say</strong></h2>
<p>Timing is where most reactivation strategies fall apart. Sending the same message to everyone 30 days after churn is easy to set up, but it ignores the fact that not all churn behaves the same. The biggest difference comes down to plan type.</p>
<p>Monthly and annual subscribers churn for very different reasons, and more importantly, they forget you at different speeds.</p>
<p>For monthly users, the relationship is short and transactional. Habits break quickly, and if you wait too long, you’re no longer reactivating, you’re reacquiring. That’s why it’s worth testing earlier touchpoints:</p>
<ul>
<li>Around <strong>7 days</strong>: while the habit is still fresh</li>
<li>Around <strong>21 days</strong>: before they fully disengage</li>
<li>Around <strong>45 days</strong>: a final attempt before they go cold</li>
</ul>
<p>For <strong>annual users</strong>, the dynamic is different. They’ve made a bigger commitment and usually had stronger initial intent. Churn is often driven by timing or changing needs, not lack of belief in the product. That gives you a longer window to re-engage:</p>
<ul>
<li>Around <strong>30 days</strong>: once they’ve had some distance</li>
<li>Around <strong>90 days</strong>: when the original use case might return</li>
<li>Around <strong>6 months</strong>: aligned with seasonal or cyclical needs</li>
</ul>
<p>The key idea is that reactivation works best when it matches the user’s <a href="https://phiture.com/mobilegrowthstack/natural-usage-habits-choosing-your-engagement-metric-a158438962c3/">natural usage habit</a>, not your CRM calendar.</p>
<p>Plan type is only one part of the picture. To make timing actually work, you also need to layer in why the user churned and how they behaved before leaving.</p>
<p>Start with their churnreason. Different reasons create very different reactivation windows:</p>
<ul>
<li><strong>“Not needed right now”</strong>
This isn’t a rejection, it’s a timing issue. Reaching out too early feels pushy because the need genuinely isn’t there. You’re better off waiting until the use case naturally returns, whether that’s tied to travel, routines, or specific moments.</li>
<li><strong>“Too expensive”</strong>
Price sensitivity doesn’t disappear overnight. Instead of immediately offering discounts, time your outreach around moments where value feels higher, like seasonal spikes, new feature releases, or more relevant use cases.</li>
<li><strong>“Didn’t get value”</strong>
This is the one case where speed matters. The longer you wait, the more the product is mentally written off. Re-engage quickly with education, guided use cases, or a clearer path to first value.</li>
<li><strong>“Technical issues”</strong>
Timing here is simple: don’t reach out until the problem is fixed. Coming back too early just reminds the user why they left.</li>
</ul>
<p>Then you can layer in behavior. Not all churned users are equal:</p>
<ul>
<li><strong>Highly engaged users who churned</strong>
Their habit just broke. Your window is short, because they’re more likely to replace you quickly. Early, relevant touchpoints matter here.</li>
<li><strong>Low-engagement users who churned</strong>
They never really built a habit in the first place. At this point, you’re not reactivating, you’re essentially reacquiring. That means slower timing and more emphasis on value explanation.</li>
</ul>
<p>
In summary, message timing shouldn’t just reflect when someone left, but why they left and how close they were to forming a habit in the first place.</p>
<h2><strong>4. Offer strategy: what works vs what feels desperate</strong></h2>
<p>Let’s be blunt. If your only reactivation lever is “20% off”, your product isn’t doing enough heavy lifting. Discounts only work if they feel timely and relevant to the user.</p>
<p>The better approach is to make your offer feel relevant, not reactive. That starts with contextual offers.</p>
<p><strong>1. Instead of a generic incentive, tie the message directly to why the user left:</strong>
“You canceled because X. Here’s a solution for that.”
This immediately makes the outreach feel personal.</p>
<p><strong>2. Then look at usage-based incentives.</strong>
Credits, limited access, or feature unlocks reduce the barrier to re-entry without forcing a full commitment. They let users ease back into the product instead of making an all-or-nothing decision.</p>
<p><strong>3. Time-based framing is another underrated lever.</strong>
For users who churned due to timing, messages like “Try again for your next trip” or “Restart when you need it” align with real-world behavior. You’re not pushing them to come back now, you’re giving them a reason to come back when it actually makes sense.</p>
<p><strong>4. Lean on product-led hooks.</strong>
New features, meaningful improvements, or integrations can be powerful reactivation drivers, especially for users who left due to missing value. This is where your product does the convincing for you.</p>
<p>Remember, the goal isn’t to win everyone back. It’s to win back the right users (high value, high intent), for the right reasons, in a way that actually sticks.</p>
<h2><strong>5. Make it frictionless to return</strong></h2>
<p>Even if your messaging is perfect, unnecessary friction can kill your reactivation efforts.</p>
<p>A lot of teams get the user to click and then lose them the moment they land back in the app because the experience feels like starting from scratch. If a returning user is treated like a brand new one, forced through generic onboarding, or dropped into an impersonal state, you’ve just undone all the work it took to bring them back. Instead, make the return feel like a continuation, not a reset.</p>
<p>Start by removing unnecessary friction. If you can avoid re-onboarding, do it. Let users pick up where they left off, with their preferences, history, and progress intact. The more familiar the experience feels, the faster they’ll get back to value.</p>
<p>Then reduce decision fatigue. Don’t make users rethink everything from scratch. Default to their previous plan, clearly highlight what’s changed since they left, and guide them toward an immediate “quick win” so they can feel progress right away.</p>
<p>Finally, consider more flexible ways to re-enter. Options like pausing instead of canceling, easy plan switching, or grace periods all lower the psychological barrier to coming back. You’re not asking for a big commitment, you’re offering a low-risk way to try again.</p>
<h2><strong>6. Ultimately, the quality of your product determines reactivation success</strong></h2>
<p>Whether your reactivation strategy works is directly tied to everything that happens before a user churns, especially your onboarding quality, activation success, value delivery, and pricing and packaging.</p>
<p>In other words, if users churn because they didn’t understand the product, didn’t experience value, or barely used it, no amount of win-back messaging will fix that.</p>
<p>I go deeper into lifecycle architecture in <a href="https://www.startapp.school/courses/lifecycle-marketing-for-apps">my course</a> on RevenueCat StartApp School. Reactivation only works when the system before it is solid.</p>
<h2><strong>Final thought</strong></h2>
<p>It’s easy to assume churned users are no longer interested in your product and might be a waste of time to pursue. However, the data shows that this is where some of the highest-leverage growth sits.</p>
<p>You already paid to acquire these users and convinced them to pay. Are you building a system where you’re top of mind when they’re ready to come back? And does that return experience help them pick up where they left off?</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[WWDC26: What’s new for subscription apps]]></title>
      <link>https://www.revenuecat.com/blog/engineering/wwdc26-whats-new-for-apps</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/wwdc26-whats-new-for-apps</guid>
      <pubDate>Tue, 09 Jun 2026 18:01:53 GMT</pubDate>
      <dc:creator><![CDATA[Austin Blake]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[Monthly-billed annual plans, save offers at cancellation, and team sales – the StoreKit and App Store changes that actually move your business.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/b5c9b1b635baf2aa836662d3805ba044824c0877-1600x800.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>WWDC26 has wrapped up and there’s a lot to dig into for subscription app businesses. We’ve been going through the session videos and release notes to figure out what Apple has changed this year that actually matters for developers building with RevenueCat.</p>
<p>Most of the keynote was focused on Siri AI and iOS 27’s design updates, but the StoreKit and App Store sessions had some of the most significant subscription business changes in years. Here’s everything worth knowing.</p>
<h2><strong>Annual subscriptions can now bill monthly</strong></h2>
<p>(By the way, this is only available outside of the United States and Singapore for now, and requires iOS/iPadOS/macOS/tvOS/visionOS 26.5 or later.)</p>
<p>Apple has introduced a new pricing option that lets customers pay for an annual subscription in monthly installments. The customer still commits to a full year, but the cost is spread across twelve monthly payments instead of charged upfront.</p>
<p>This is a meaningful new tool for paywalls. Annual plans convert well because of the price-per-month math, but the upfront cost is a real barrier for a lot of users. Monthly-billed annual plans give you the retention benefits of an annual commitment without asking users to hand over a year’s worth of money at once.</p>
<p>You can add this billing option to new or existing one-year auto-renewable subscriptions in App Store Connect.</p>
<h3><strong>What changes in your code</strong></h3>
<p>The entry point on the client side is a new pricingTerms property on a product’s subscription info. It returns an array of every billing plan available for that product. Every annual subscription has at least one entry with a <code>billingPlanType</code> of <code>.upFront</code>. If you’ve configured a commitment plan, a second entry shows up with a <code>billingPlanType</code> of <code>.monthly</code>.</p>
<p>There’s a new <code>preferredSubscriptionPricingTerms</code> view modifier for <code>SubscriptionStoreView</code> if you’re using StoreKit’s built-in UI. For custom paywall UI, read <code>pricingTerms</code> directly to get both the monthly price and the total 12-month commitment price so you can display both clearly to users.</p>
<p>To trigger the purchase, pass the billing plan as a purchase option:</p>
<pre><code class="language-text">let result = try? await product?.purchase(options: [.billingPlanType(.monthly)])</code></pre>
<h3><strong>What changes on your server</strong></h3>
<p>The JWS transaction payload has new fields to be aware of: <code>billingPlanType</code>, <code>renewalBillingPlanType</code>, and <code>commitmentInfo</code>. The <code>commitmentInfo</code> block tells you which billing period a customer is in, the total number of billing periods, the committed price, and the commitment expiration date.</p>
<p>This is important: a single annual product ID can now represent two completely different billing arrangements. Make sure your backend is reading and storing these new fields, or you’ll end up with incorrect entitlement data.</p>
<h3><strong>What this means for RevenueCat customers</strong></h3>
<p>Your paywalls will need to show both the monthly price and the total 12-month commitment. “Pay $4.99/month, billed over 12 months” is not the same thing as “$59.99/year” and your users will expect to see both.</p>
<p>In analytics, committed monthly billing is not the same as a regular monthly subscription. If you don’t segment them, your LTV models will be wrong.</p>
<p>One testing caveat: there’s a known Xcode bug where <code>pricingTerms.commitmentInfo.price</code> returns an incorrect price for commitment plans in StoreKit Testing. Keep that in mind as you build and test.</p>
<p>RevenueCat supports committed monthly billing out of the box. We parse and store the new <code>billingPlanType</code>, <code>renewalBillingPlanType</code>, and <code>commitmentInfo</code> fields automatically, so you don’t need to build custom backend logic to handle them. See the <a href="https://www.revenuecat.com/docs/subscription-guidance/apple-monthly-with-commitment">docs</a> for implementation details.</p>
<h2><strong>Retention Messaging lets you save subscribers at the moment they cancel</strong></h2>
<p>Retention Messaging is one of the most interesting business tools Apple has shipped in a while. When a subscriber goes to cancel, the App Store can now show them a message or offer that you’ve configured. This is access to a moment in the subscription lifecycle that developers previously had no visibility into.</p>
<p>There are two ways to use it.</p>
<p>The App Store Connect path requires no custom server infrastructure — you configure messages and offers directly in App Store Connect and Apple handles the rest. The real-time path is more powerful but optional.</p>
<p><strong>App Store Connect configuration</strong> is the simpler path. You set up messages in App Store Connect, optionally attach a retention offer and creative assets from the new Asset Library, and Apple displays them during the cancellation flow. No additional server infrastructure required.</p>
<p><strong>Real-time Retention Messaging</strong> is more powerful. When a user tries to cancel, the App Store makes a server-to-server request to an endpoint you configure, passing fields like <code>originalTransactionId</code>, <code>productId</code>, and <code>userLocale</code>. Your server responds with what to show: a message, an alternate product, or a promotional offer.</p>
<p>This opens up subscriber-aware save flows that weren’t possible before. You can offer a discounted annual plan to a loyal monthly subscriber, suggest a lower tier to someone on a premium plan, skip the offer entirely for someone who already redeemed one recently, or show different messaging by market.</p>
<p>Apple shared early data in the WWDC session (session 309): subscriptions using Retention Messaging saw an average save-rate lift of 1.4 percentage points, equivalent to an 82% increase. Promotional offer messages had the strongest lift at 5.5 percentage points. Apple noted that results vary by developer.</p>
<p>One important technical requirement: real-time Retention Messaging requires a fast server response. Apple runs a sandbox performance test and your endpoint has to pass before you can use real-time messaging in production. If your server doesn’t respond in time, the App Store falls back to your App Store Connect configuration. Plan for that fallback path.</p>
<p>Real-time retention also works with the new commitment billing plans. You can respond to a cancellation request by offering a switch to the monthly-billed annual plan, which is a useful save option for users who are cancelling because of the upfront cost.</p>
<h3><strong>What this means for RevenueCat customers</strong></h3>
<p>Retention offer transactions have their own identifiers in the JWS payload: <code>offerType</code>: <code>5</code>, <code>offerIdentifier</code>, and <code>offerDiscountType</code>. Make sure your analytics pipeline tracks these separately from other promotional offers so you can measure actual save rates, post-save retention, and which offer types are performing.</p>
<p>RevenueCat handles the real-time Retention Messaging server side for you. The dashboard lets you configure all four message types — text, text + image, switch plan, and promotional offer — and manages the response window requirement automatically. You’ll still need to complete Apple’s approval form to register the endpoint, but the infrastructure is taken care of. Full setup steps are in the <a href="https://www.revenuecat.com/docs/platform-resources/apple-platform-resources/apple-retention-messaging-api">docs</a>.</p>
<h2><strong>Subscriptions can now be sold to groups and organizations</strong></h2>
<p>Apple has introduced two new ways to sell subscriptions beyond the individual consumer model.</p>
<p><strong>Group purchases</strong> let a single subscriber buy multiple seats and invite others to join from inside your app. Apple handles the invitation flow; each person joins using their own Apple Account. This is available for in-app purchases you build yourself with the StoreKit 2 purchase flow.</p>
<p><strong>Volume purchasing</strong> puts your subscription in front of enterprise and education buyers through Apple Business Manager and Apple School Manager. Seat assignments are handled through existing device management workflows, which means IT can deploy your app to a fleet of devices without users needing to do anything themselves.</p>
<p>Volume purchasing is available this fall. Group purchases arrive this winter. Both are configured in App Store Connect and are on by default for most auto-renewable subscriptions (Family Sharing subscriptions are opted out).</p>
<p>Volume pricing bands are configurable: you can set up to five price tiers with reduced per-seat pricing for larger purchases.</p>
<p>On the StoreKit side, Apple added <code>Transaction.OwnershipType.assigned</code> and <code>Transaction.RevocationType.assignmentRevoked</code> enum values to support volume purchases. Transaction query methods also now return transactions assigned to a Managed Apple Account.</p>
<h3><strong>Entitlement design is the hard part</strong></h3>
<p>If your app has any team, education, or organization use case, think through these before you enable group subscriptions:</p>
<ul>
<li>The buyer is often not the end user</li>
<li>One transaction can produce multiple entitled seats</li>
<li>Seats can be assigned, revoked, or reassigned</li>
<li>Volume purchases may arrive through device management rather than the app itself</li>
<li>Volume pricing changes your per-seat revenue metrics</li>
</ul>
<p>This is genuinely new territory for most subscription apps. The purchasing layer is simpler now, but the entitlement logic is more complex.</p>
<p>One implementation detail worth flagging now: both group purchases and volume purchasing require StoreKit 2. If your app hasn’t migrated yet, it’s worth prioritizing that before these features become available later this year to avoid delays.</p>
<p>We’ll have updates later this year on how to implement this in RevenueCat.</p>
<h2><strong>Cross-developer Bundles and Suites</strong></h2>
<p>This is the App Store change getting the most attention in the broader developer community, and it’s worth understanding what it actually is.</p>
<p><strong>App Store Bundles</strong> let developers from different companies package their subscriptions together and offer them at a combined discount. Users can still buy each subscription individually; the bundle is an additional offering alongside the standalone products.</p>
<p><strong>App Store Suites</strong> are different: a collection of subscriptions that only exist as a package and can’t be purchased separately. They’re designed for tightly related apps from different developers that make more sense together than apart.</p>
<p>Both formats address something developers have been asking for for years: cross-developer bundling without requiring the same parent company.</p>
<p>The StoreKit API for Bundles and Suites is available to prototype in Xcode 27 today. New <code>Product.ProductType</code> values represent Bundles and Suites, and <code>Product.SubscriptionInfo.BundledSubscription</code> lets you fetch merchandising data about subscriptions in a bundle. <code>Transaction</code> and <code>RenewalInfo</code> also have new fields for Bundle and Suite status.</p>
<p>That said, Apple has said full program details are coming later in 2026. Prototype against the API now, but don’t plan a ship date yet.</p>
<h3><strong>What this means for your RevenueCat Dashboard</strong></h3>
<p>Bundle and Suite subscribers will look different in your analytics from direct subscribers. Revenue attribution, churn analysis, and per-app subscriber counts will all need to account for the bundle layer. We’ll have more to share on RevenueCat support as the program details become clearer.</p>
<h2><strong>App Store Merchandising: new placements for Creative Assets</strong></h2>
<p>Apple is adding new creative placements on the App Store this year. Developers can now supply rich images and videos that appear in product page headers and search results, and these assets also work with custom product pages and product page optimization tests. They’re managed through a new Asset Library in App Store Connect.</p>
<p>A few things worth noting about how this works:</p>
<ul>
<li>Assets can be submitted for App Review independently from an app update, so you can refresh seasonal creative or coordinate with an Apple Ads campaign without shipping a new build</li>
<li>The Asset Library centralizes all your creative across custom product pages, In-App Events, and now these new placements, so you’re not uploading the same assets in multiple places</li>
<li>A new product page preview lets you see exactly how your page looks across languages, Dark Mode, and device orientations before you publish</li>
</ul>
<p>The same Asset Library also feeds Retention Messaging. That means your creative team can manage App Store acquisition assets and cancellation-save assets from the same place. That’s a meaningful workflow improvement for teams that have historically treated those as completely separate functions.</p>
<h2><strong>Personalized Collections and App Notes</strong></h2>
<p>Apple is rolling out two new discovery surfaces on the App Store. Personalized Collections are curated lists that appear on the Apps, Games, and Search tabs, surfacing apps based on each user’s interests and behavior. App Notes are short explanations that appear alongside a recommendation, telling users why a specific app is being suggested to them.</p>
<p>Both are already rolling out in English (US), with more languages and regions coming later this year.</p>
<p>There’s no developer action required to participate. That said, apps with complete, accurate metadata — clear descriptions, relevant keywords, up-to-date screenshots — will be better positioned to surface in relevant collections. It’s a good time to audit your App Store listing if you haven’t recently.</p>
<h2><strong>Offer Code Redemption gets a proper result</strong></h2>
<p>A smaller but welcome StoreKit update: the offer code redemption sheet now returns a <code>VerificationResult</code> when redemption completes, rather than firing and forgetting.</p>
<p>On success, you get a <code>VerificationResult</code> containing a Transaction to verify and finish. On failure, you get a descriptive error. The API also now takes a set of <code>RedeemOption</code> values to configure the flow.</p>
<p>This brings offer code redemption in line with how the rest of StoreKit 2 works. If you have a custom “redeem code” button in your app, it’s worth updating the transaction handling path to use the new result.</p>
<h2><strong>Unified App Review submissions</strong></h2>
<p>A useful operational change: you can now group In-App Purchase products into a single App Review submission alongside other item types like in-app events, custom product pages, and product page optimizations.</p>
<p>Previously you had to submit each product individually and track them separately. Now you select everything together, submit once, and track review status from one view. The App Store Connect API’s <code>reviewSubmissions</code> collection now supports IAP, subscription, and subscription group resources. Apple is deprecating the older per-resource submission endpoints in favor of this unified path.</p>
<p>If you have tooling or automation built around the old individual submission flow, start migrating now rather than waiting.</p>
<h2><strong>App Review Guidelines: saturated categories now face removal, not just rejection</strong></h2>
<p>Alongside the new monetization features, Apple updated its App Review Guidelines in a way that’s worth knowing about.</p>
<p>Previously, guideline 4.3(b) warned developers not to submit new apps in categories that were already crowded with low-effort entries. Apple would reject new submissions in categories like flashlight apps, fortune telling apps, and dating apps unless they offered something meaningfully different.</p>
<p>The updated guidelines go further. Apple now says it may remove existing apps in well-established saturated categories if they are not “updated, improved, or attracting customers.” The list of targeted categories has also been expanded to include wallpaper apps, simple timers, and sound effects. Apple also added language calling repeated submissions of low-effort apps “mediocre” and “low-quality,” with a warning that developers who keep submitting them may lose Apple Developer Program access entirely.</p>
<p>This is a meaningful shift from “we won’t accept new ones” to “we may remove the ones already there.”</p>
<p>It connects directly to Apple’s new Personalized Collections discovery feature. Apple is building better ways for users to find quality apps, and low-quality clutter degrades those surfaces. The two moves go together.</p>
<p>For existing developers building legitimate subscription businesses, this is more tailwind than headwind. A cleaner App Store, optimized for apps that are actively maintained, is a welcome addition!</p>
<h2><strong>Summary</strong></h2>
<p>WWDC26 brought more than just the usual updates—it was a really exciting year for subscription apps! Apple has introduced some fantastic new ways to help your business grow and connect with users. Here’s a quick look at the highlights:</p>
<ul>
<li><strong>Committed annual billing</strong> gives you a new middle-ground pricing option that could convert users who balk at the annual upfront cost</li>
<li><strong>Retention Messaging</strong> gives you a save opportunity at the moment of cancellation that didn’t exist before</li>
<li><strong>Group purchases and volume purchasing</strong> open up B2B and team sales paths without building your own billing infrastructure</li>
<li><strong>Cross-developer Bundles and Suites</strong> are coming later this year, with an API available now to prototype</li>
<li><strong>Creative Assets</strong> expand your merchandising surface on the App Store</li>
<li><strong>Updated App Review Guidelines</strong> raise the quality floor across the store</li>
</ul>
<p>We know there’s a lot to dig into here, especially regarding analytics and how you design your entitlements. We’re already hard at work figuring out the best ways for RevenueCat to support you through these changes, and we’ll be sharing more updates as we learn more. We’re excited to see what you build!</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Don’t trust your Flutter app: verifying RevenueCat entitlements with the Firebase Extension]]></title>
      <link>https://www.revenuecat.com/blog/engineering/verify-revenuecat-entitlements-flutter-firebase</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/verify-revenuecat-entitlements-flutter-firebase</guid>
      <pubDate>Tue, 09 Jun 2026 09:58:34 GMT</pubDate>
      <dc:creator><![CDATA[Daria Orlova]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[Anyone can patch isPremium to true and stream your premium content for free — here's how to verify RevenueCat entitlements properly with Firebase.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/e0a031976ff328970b752e382fb4ce6ef4a55fc6-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>You spent months working on your shiny new app (or hours vibecoding it), and you’re almost ready to earn millions of dollars with it. Just one last step: add purchases, and you’re ready to go. But… not so fast. While on the surface it may seem obvious: you just check isPremium and unlock the premium content, in reality, there are many more caveats. Can malicious actors bypass this check? How easy is it? And how can you prevent unsanctioned access to premium content and protect your resources? In this article, we will discuss these questions and propose efficient solutions, without compromising security. This matters even more if you’re an indie dev like me and don’t have unlimited resources.</p>
<p>Now let’s build an app!</p>
<h2>What are we building?</h2>
<p>To explore the concepts, we will start by building a simple sample app called “Catflix”. As you probably guessed from the name, it is a video streaming service (for cats, about cats…). The use case is simple: all users can see the available shows in the catalog, but only premium users can watch unlimited episodes and premium shows. Premium is unlocked via a renewable subscription.
</p>
<p><em>Note: the sample app and code in this article will be a Flutter app, but the logic applies to other mobile clients, since most of the work involves Firebase. The app uses RevenueCat for purchases. More on how to integrate RevenueCat into your Flutter app: </em><em><a href="https://www.revenuecat.com/docs/getting-started/installation/flutter">https://www.revenuecat.com/docs/getting-started/installation/flutter</a></em></p>
<p>Here’s how it looks:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/68e532880aa0ce144d5d3c684c3a3e423548166b-1920x1080.png" alt=""/></figure>
<p>You can follow along with the <a href="https://github.com/darjaorlova/catflix">code in this GitHub repository</a>. To get a complete understanding of why we’re making these technical decisions, we will build our architecture from the ground up.</p>
<h2>Level 1: Client-only check</h2>
<p>Imagine that your movie streaming data is stored in a remote database. The database itself is unrestricted (already a red flag 🚩), but you need a way to gate premium access. The most straightforward approach is to use the RevenueCat SDK to check if a user has a specific entitlement and grant access accordingly.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/42b8df27cafba227fcdc49a4eeecbae5760b8c02-1920x1080.png" alt=""/></figure>
<p><em>Throughout the article, the 🔑 marks where the entitlement check actually lives.</em>
<em>Watch where it moves as we go.</em></p>
<p><em>Note: this step assumes you have integrated the RevenueCat SDK into your app and set up purchases in the App Store and Google Play. For this example, we will omit the actual store setup and rely on RevenueCat test purchases, but here’s the full documentation on how to integrate everything in your real app: </em><em><a href="https://www.revenuecat.com/docs/projects/overview">https://www.revenuecat.com/docs/projects/overview</a></em><em>.</em></p>
<p>In code, it looks something like this:</p>
<pre><code class="language-javascript">// Sample in code: https:\/\/github.com\/darjaorlova\/catflix\/blob\/main\/lib\/catflix_repository.dart#L127

final info = await Purchases.getCustomerInfo();

final isPremium = info.entitlements.active.containsKey('catflix Premium');

if (isPremium) {

  _playPremiumShow();

}</code></pre>
<p>The appeal of this approach is obvious: it requires only a couple of lines of code, with no extra boilerplate or complicated verifications. However, that very appeal is also the obvious problem: since the check runs purely on the client side, a malicious user can decompile it, patch isPremium to always return true, and abuse your premium resources.</p>
<p><strong>Conclusion: this approach should never be used in production!</strong></p>
<p>Now, let’s look at the first step we can take to make our app’s premium content more secure.</p>
<h2>Level 2: Client-only check + Firebase App Check</h2>
<p>The code on the client side is unsafe by definition — it lives on the user device, and honestly, not a lot of things are stopping a potentially malicious user from tampering with the source code and doing all sorts of nasty things. For example, they can easily change your isPremium check to a hard-coded true, granting access to premium content without a subscription. But we’re in luck, because mechanisms exist that allow us to secure our apps from this vector of attack. One of those mechanisms is Firebase App Check.</p>
<p><em>Note: We won’t discuss the pros and cons of spinning up your own backend versus using a Backend-as-a-Service (BaaS), or the different types of BaaS available. Let’s just assume you have done your research and reached the same conclusion as I did: Firebase is the solution.</em></p>
<p><a href="https://firebase.google.com/docs/app-check">Firebase App Check</a> uses underlying platform attestation mechanisms (Play Integrity by Google and App Attest/Device Check by Apple). It sends a special token with each request to Firebase services; this token is invalid if it was generated from a tampered device or app version (e.g., an Android version that wasn’t signed with your Play Store certificate). If a malicious user repackaged your app and flipped isPremium to true, App Check would refuse the new binary’s tokens, and Firestore would reject every request, keeping your premium content safe. <em>App Check doesn’t catch every form of tampering, but we’ll come back to that in a moment. </em><strong>Therefore, App Check is a must-enable feature if you’re using Firebase services and ESPECIALLY if you’re using the </strong><a href="https://firebase.google.com/docs/ai-logic"><strong>Firebase AI Logic client SDK</strong></a>.</p>
<p>In a nutshell, here is how Firebase App Check works:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/c91d468cdae6975e08267e70e71409384a5baa34-1920x1080.png" alt=""/></figure>
<p><a href="https://firebase.google.com/docs/app-check/flutter/default-providers">Setting up App Check</a> is straightforward. It is highly recommended to enable it from the start, before you even launch your app. But even if you haven’t done so before, it is possible and essentially required to set it up for an existing app; you’ll just need to monitor and wait for some time for your existing users to update their app before enforcing it.</p>
<p>So with this approach, our architecture would look like this:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/08ee4e1c7919b925467a5690555cb702b8e3a2ef-1920x1080.png" alt=""/></figure>
<p>Notice that the 🔑 has not moved. We have added an extra layer of security, but the entitlement decision still lives in the client code. The crucial thing to understand is what App Check actually attests: it confirms a request came from a genuine, untampered instance of your app on a genuine device. It does not confirm that this genuine app instance is telling the truth about the user. That gap is the problem. App Check is great at stopping the basic attacks: someone hitting your Firestore straight from the command line, or repackaging your app into a tampered build. What it can’t do is vouch for the decision your app makes once it’s legitimately running, because that decision happens on a device the user fully controls. And you don’t even need to tamper with anything to see why that matters. Picture our Level 2 setup the moment a paying user’s app fetches the real video URL. That user can read the URL straight out of the network traffic and share it around, and it keeps working for anyone who has it. App Check faithfully protected the lookup, but it can’t protect an asset once a trusted client has been handed it. As long as the client is making the final call, your premium content is vulnerable. To truly secure it, we need to stop trusting the client altogether and move the decision-making process somewhere safe.</p>
<p>Thankfully, with Firebase, we have a couple of options to achieve this without spinning up a whole backend infrastructure. Let’s see what they are.</p>
<h2>Level 3: Server-verified entitlements</h2>
<p>Firebase offers a solution called <a href="https://firebase.google.com/docs/functions">Cloud Functions</a>, which allows you to write and deploy custom backend logic without the overhead of managing a full server infrastructure. We will explore these in more detail shortly, but first, we need to address a prerequisite: user authentication.</p>
<h2>Identifying Firebase users in RevenueCat</h2>
<p>To verify entitlements on the server, we must link our Firebase users to their RevenueCat identities. For this sample app (and many real-world use cases), <a href="https://firebase.google.com/docs/auth/flutter/anonymous-auth">Firebase Anonymous Authentication</a> is sufficient, though you can later upgrade to email or SSO providers. From <a href="https://firebase.google.com/docs/functions">Firebase Cloud Functions</a>‘ point of view, an anonymous user is just as authenticated as an email/SSO user: the function receives a real UID it can trust, signed by Firebase. That’s all the server needs to look the user up in RevenueCat.</p>
<aside class="tip"><strong>Good to know</strong><p><em>If a user uninstalls and reinstalls the app, they will be assigned a new anonymous UID. If they previously made purchases, they will need to use the </em><em><a href="https://www.revenuecat.com/docs/getting-started/restoring-purchases">RevenueCat restore function </a></em><em>to regain access to their entitlements.</em></p></aside>
<p>Linking users is very simple, but extremely important for the next steps:</p>
<pre><code class="language-javascript">// Sample in code: https:\/\/github.com\/darjaorlova\/catflix\/blob\/main\/lib\/bootstrap_firebase.dart#L127 
 final credential = await FirebaseAuth.instance.signInAnonymously();
 await Purchases.logIn(credential.user!.uid);
</code></pre>
<p>This will allow us to identify our user purchases in our database going forward. Now, back to our Cloud Functions.</p>
<h2>Understanding Firebase Cloud Functions</h2>
<p>If you are already familiar with <a href="https://firebase.google.com/docs/functions">Firebase Cloud Functions</a>, you can skip this part. Otherwise, here’s a quick intro:</p>
<ol>
<li><strong>Firebase Cloud Functions vs. Google Cloud Functions:</strong>
While both allow you to execute code in isolation, Google Cloud Functions is the underlying technology, supporting many languages (Go, Java, Python, etc.). Firebase Cloud Functions is built on top of Google Cloud Functions and is specifically tailored for the Firebase ecosystem. It provides libraries that make working with Firebase much easier, offering features like user authentication and App Check out of the box. Due to this specialized support, Firebase Cloud Functions currently supports Node.js (JavaScript and TypeScript), Python, and Dart (experimental).</li>
<li><strong>Types of Firebase Functions:</strong>
Firebase Functions generally fall into three categories: HTTP functions, Callable functions, and Background Triggers.</li>
<li><strong>HTTP functions</strong> are for when you need functions openly available on the web (e.g., for a web app or a third-party API). This option requires you to manually handle security concerns, including user authentication validation, caller verification (App Check), and managing CORS.</li>
<li><strong>Callable functions</strong> are designed to be triggered directly from your client app using Firebase SDKs. Firebase handles the heavy lifting for you: CORS is automatically managed, and Firebase Auth and App Check tokens are automatically verified and injected directly into the function’s context. Because of this seamless, built-in security, callable functions are the perfect choice for safely verifying a user’s RevenueCat entitlements from your mobile app.</li>
<li><strong>Background Triggers</strong> (or event-driven functions) run automatically in the background in response to events within your Firebase project, such as a new user sign-up or a document update in Firestore. In a RevenueCat architecture, these are incredibly useful. For instance, you might use an HTTP function to receive a RevenueCat webhook, write the user’s new subscription status to Firestore, and then let a Background Trigger automatically provision their premium access the moment that database document changes.
</li>
</ol>
<p>We will use Dart, which has just <a href="https://firebase.blog/posts/2026/05/dart-functions-exp">recently become available in experimental mode</a>, to write our Cloud Functions. That’s a great addition (and something the community has been waiting for years!), because using Dart keeps us in one language end-to-end, lets us share models between client and server, and avoids context-switching mid-feature.</p>
<p><em>Note: The Dart SDK is currently in experimental support (announced April 2026), so APIs may change. Always consult the latest documentation. For more information on how to set up and deploy Dart Cloud Functions, check the </em><em><a href="https://firebase.google.com/docs/functions/start-dart">documentation</a></em><em>. We will use the Dart SDK for this example, but the approach itself is fully applicable to Firebase Cloud Functions in general, with any supported SDK. </em><em><strong>Another thing to know is that using Firebase Cloud Functions requires you to be on the pay-as-you-go Firebase plan, Blaze</strong></em><em>.</em></p>
<h2>Implementing Cloud Function for movie-playing functionality</h2>
<p>In our Catflix app, we want to make sure that only premium users can stream premium movies. To do that, we will implement a <a href="https://firebase.google.com/docs/functions/callable">callable function</a> to verify the RevenueCat entitlement. If the user has a premium subscription, it returns a streaming URL; otherwise, it throws a “subscription required” error. So the code will look something like this:</p>
<pre><code class="language-javascript">// Sample in code: https:\/\/github.com\/darjaorlova\/catflix\/blob\/main\/functions\/bin\/server.dart#L14 
void main(List&lt;String&gt; args) async {
 await runFunctions((firebase) {
   firebase.https.onCall(name: 'play', (request, response) async { // #1
     if (request.auth == null) { // #2
       throw UnauthenticatedError(
         'Sign-in is required to play a show.',
       );
     } 
     final isPremium = /* CHECK ENTITLEMENT HERE */; //#3
     if (isPremium) { 
	 // lookup the playbackUrl in database
       return CallableResult({'playbackUrl': playbackUrl});
     } else {
       throw PermissionDeniedError('Premium required to play this title.');
     }
   });
 }
}</code></pre>
<p>We’re doing several things here:</p>
<ol>
<li>We register a callable function called play</li>
<li>We check if the user is authenticated with Firebase, and if not, throw an UnauthenticatedError</li>
<li>We check if the authenticated user has premium entitlement to access this content, and if not, we throw a PermissionDeniedError</li>
</ol>
<p>
And then on the client side, to call this function:</p>
<pre><code class="language-javascript">final result = FirebaseFunctions.instance.httpsCallableFromUrl(URL).call();</code></pre>
<p>Now, to the fun part: how do we actually check user premium eligibility? For this, we have two options: direct calls to the RevenueCat API and custom webhook functions.</p>
<h3>Option 1: Direct calls to RevenueCat API</h3>
<p><a href="https://www.revenuecat.com/docs/api-v2">RevenueCat provides an API</a> we can use to request a user’s entitlement status. The base flow is pretty simple: whenever we need to check a user’s premium status, we make a request to the RevenueCat API and receive back the user entitlements. This flow is simple to wrap your head around and implement. It also doesn’t require you to do any bookkeeping yourself, as RevenueCat provides you with up-to-date info on demand.
</p>
<p>With this approach, our architecture would look like this:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/4bfb391b38099fbaaaafaa295820d5544ef74a61-1920x1080.png" alt=""/></figure>
<p>And in pseudocode, the entitlement check would look something like this:</p>
<pre><code class="language-javascript">// Sample in code: https:\/\/github.com\/darjaorlova\/catflix\/blob\/main\/functions\/lib\/play\/play_from_api.dart#L62 
// Pseudocode: error handling, security checks, type-checks, etc. omitted for clarity.
const _rcApiBase = 'https:\/\/api.revenuecat.com\/v2';
// Plain config: RevenueCat's project id (not sensitive, not a secret).
final _rcProjectId = defineString('RC_PROJECT_ID');
// Secret: the RevenueCat secret API key. Cloud Secret Manager.
final _rcApiKey = defineSecret('RC_API_KEY');'
const _entitlementLookupKey = 'catflix Premium';

Future&lt;bool&gt; _hasActivePremium(String uid) async {
  final response = await httpGet(
    '$_rcApiBase\/projects\/$_rcProjectId'
    '\/customers\/$uid'
    '\/active_entitlements\/$_entitlementLookupKey',
    headers: {'Authorization': 'Bearer $_rcApiKey'},
  );
  return response.statusCode == 200;
}
</code></pre>
<p>A note on the API key. This is a project-scoped <strong>secret</strong> key (created in the RevenueCat dashboard under API Keys -&gt; New secret API key), and it’s different from the public SDK key the client uses. <strong>It should never be shipped in client code or sit in your repo</strong>. In Cloud Functions, inject it as a runtime secret and read it from the function’s environment. Scope the key to the minimum permissions you need (<em><strong>read</strong></em> on customers is enough for this) and rotate it independently of everything else.</p>
<p><strong>How to access sensitive parameters in Firebase Cloud Functions</strong></p>
<p>Firebase Cloud Functions have their own way of defining configuration parameters, and it separates plain config from secrets. Secrets are backed by Google Cloud Secret Manager: stored encrypted, and injected only into the functions you explicitly bind them to. Use a secret for anything sensitive, like your RevenueCat API key, and plain parameters for everything else, like your RevenueCat project ID. The experimental Dart SDK supports both, with defineSecret(‘RC_API_KEY’) for the key and defineString(‘RC_PROJECT_ID’) for the rest. Here are the <a href="https://firebase.google.com/docs/functions/config-env">docs</a>.

Now, even if a malicious user manages to change the value of isPremium to true on the client side, they still won’t be able to access the premium features because our Cloud Function simply won’t return the premium content.</p>
<p>Unfortunately, this approach isn’t the most efficient one, especially if you plan to scale, for a few reasons:</p>
<ol>
<li><strong>Overhead on every check.</strong> Each premium check now costs an extra HTTP round-trip to RevenueCat, which adds latency to every gated request, and your function is billed for the time it spends waiting on that call.</li>
<li><strong>Service unavailability and </strong><strong><a href="https://www.revenuecat.com/docs/api-v2#tag/Rate-Limit/Rate-Limit-Headers">rate limits</a></strong><strong>.</strong> If the RevenueCat API is unavailable for any reason, your check will fail. Additionally, RevenueCat enforces rate limits that you could hit under burst traffic. This could temporarily deny a paying user access to a paid feature, and we don’t want that.
</li>
</ol>
<p>You might be thinking: can’t I just cache the result? You can, but then it’s your responsibility to keep the cache fresh, which is exactly the bookkeeping RevenueCat was doing for you, and entitlement state can flip the moment a subscription renews, lapses, or gets refunded.
Direct calls are only viable if you need to perform checks very rarely and are okay with the possibility of the request failing (for example, if you can simply reschedule the task for later). For a general modern app, this isn’t the most efficient solution, and we need to go a different route. The fix is to invert the flow: instead of asking RevenueCat every time for user entitlement status, let RevenueCat tell us once, the moment something changes.</p>
<h3>Option 2: Custom Webhook Function</h3>
<p>Instead of making the request from your end each time you need to check the status, you can <a href="https://www.revenuecat.com/docs/integrations/webhooks">use webhooks to reverse this pattern</a>. You can define a function that RevenueCat will call each time there is a status change. This requires quite a bit of upkeep on your end, such as:
</p>
<ul>
<li><strong>Validating that the request really came from RevenueCat</strong>. RevenueCat sends a static authorization header value you configure in the dashboard. You need to verify it on every request, or anyone who finds your endpoint URL can call it and grant themselves premium access.</li>
<li><strong>Handling retries and idempotency. </strong>RevenueCat retries failed deliveries and, in a rare case, may send duplicate events. Your code needs to process duplicate events idempotently, e.g., by keeping track of the id of the event.</li>
<li><strong>Storing and updating user status</strong>. Each event you receive describes a change to a specific RevenueCat customer’s entitlements. Your code needs to find the matching user in your own database and apply the right update (grant, revoke, change expiry), so the rest of your app has an up-to-date source of truth to read from.</li>
</ul>
<p>In this case, the architecture would look like this:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/a01fc055984fb95601b76c03d30df566b1e5387d-1920x1080.png" alt=""/></figure>
<p>But the benefits are worth it:</p>
<ol>
<li>Requests between you and RevenueCat happen only when something actually changes with a user’s entitlement, not on every premium check in your app.</li>
<li>Your database holds the current premium status for all users, so checking it is a quick local read, instead of sending an expensive request to the RevenueCat API that could also fail or hit a rate limit.</li>
</ol>
<p><em>Note: You can read more about working with RevenueCat webhooks in the documentation: </em><em><a href="https://www.revenuecat.com/docs/integrations/webhooks">https://www.revenuecat.com/docs/integrations/webhooks</a></em></p>
<p>While the maintenance of the webhook option is a bit of overhead, if you use Firebase, you are in luck – RevenueCat actually provides a Firebase extension that handles all the boring plumbing and removes all the headache.</p>
<h3>Option 3: RevenueCat Firebase Extension</h3>
<p>First, what exactly is a <a href="https://firebase.google.com/docs/extensions">Firebase Extension</a>? Simply put, it’s a pre-packaged, configurable backend solution that automates specific tasks so you don’t have to write and maintain the server code yourself. In our case, it provides deployable, pre-written Cloud Functions that handle all the boring boilerplate.</p>
<p>The RevenueCat extension specifically provides an endpoint for RevenueCat webhooks to call whenever a user’s status changes. These functions handle request validation, data parsing, and syncing that state directly into a Firestore document assigned to the specific user. Once you’ve configured the extension, your own Cloud Functions can simply read the latest entitlement status directly from your Firestore database.</p>
<p>You configure the Extension once, and then everything in the purple rectangle is done automatically for you:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/07b3039b363194f38ace85c098c700961844fa4b-1920x1080.png" alt=""/></figure>
<p>This way, the data is always up to date in your own database, you don’t make unnecessary API calls, and you don’t need to do any manual plumbing or maintenance. All of the benefits of custom webhooks and none of the headache.</p>
<p>Let’s walk through the setup of the RevenueCat Firebase Extension.</p>
<p><em>Note: </em><em><a href="https://www.revenuecat.com/docs/integrations/third-party-integrations/firebase-integration">this guide</a></em><em> contains all the steps to enable the Extension. It also walks you through connecting to Google Analytics, but the analytics portion is outside the scope of this article.</em>
</p>
<ol>
<li>Start the integration in your RevenueCat console and generate a shared secret. You will need it in the next step.</li>
</ol>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/23d74efcd979eeda9bca2d2c57d27618aee66414-1364x2048.png" alt=""/></figure>
<ol>
<li>Install the extension from the Firebase Extension Hub: <a href="https://extensions.dev/extensions/revenuecat/firestore-revenuecat-purchases">https://extensions.dev/extensions/revenuecat/firestore-revenuecat-purchases</a></li>
<li>Fill in the information. There are two optional fields: the <strong>location of the customer collection</strong> and the <strong>RevenueCat Webhook Events Firestore collection</strong>. For our use case, they are both required. These are the paths in Cloud Firestore where the RevenueCat data for your app and for your users will be stored. Also, set <strong>ENABLED</strong> for custom claims; we will discuss them soon.</li>
</ol>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/7514d081cf74c7ac6150cd73b5f3d3e761c24dd4-1680x1608.png" alt=""/></figure>
<ol>
<li>After you have installed the extension (it will take around five minutes to install itself), you will see a webhook URL and Firebase security rules:</li>
</ol>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/da2e24f40a820d655fd2ee972eb702c01d036e84-1384x856.png" alt=""/></figure>
<p><em><strong>It is very important to copy these rules and add them to your Cloud Firestore Security rules. They control access to purchase information. Learn more about Firebase Security Rules </strong></em><em><strong><a href="https://firebase.google.com/docs/rules">here</a></strong></em><em><strong>.</strong></em>
</p>
<ol>
<li>Also, from the previous screen, copy the webhook URL and add it to your RevenueCat console:</li>
</ol>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/df1c2133464a193d6709bfcd58aa75508b6630f8-1713x856.png" alt=""/></figure>
<p>You’re done with installation! Now, when your users make a purchase, this information will be synced to your Firestore database, as well as set in your <a href="https://firebase.google.com/docs/auth/admin/custom-claims">user’s auth custom claims </a>(we will return to them in a moment). To finalize our task of making our play function gate premium access, let’s see how to do it with our current setup. Again, we have two options: reading from the database or reading from the user’s custom claims.</p>
<h3>Reading entitlement data from Firestore</h3>
<p>There isn’t much to explain here, because the code is as simple as it gets: it’s literally just a read from your own Firestore database:</p>
<pre><code class="language-javascript">// Sample in code: https:\/\/github.com\/darjaorlova\/catflix\/blob\/main\/functions\/lib\/play\/play_from_db.dart 
// Pseudocode: error handling, type-checks, and configurable
// collection paths omitted for clarity.
Future&lt;bool&gt; _hasCatflixPremiumInFirestore({required String uid}) async {
  final snapshot = await firebaseApp
      .firestore()
      .collection('customers') // the path you specified when configuring extension
      .doc(uid)
      .get();

  final entitlement = snapshot.data()?['entitlements']?['catflix Premium'];
  if (entitlement == null) return false;

  final expiresAt = DateTime.parse(entitlement['expires_date']);
  return expiresAt.isAfter(DateTime.now());
}</code></pre>
<p>And you can verify in your Firestore database that this information exists:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/c3a0b1f26826cfd3b29d7610601e0f451f6a5c25-2048x607.png" alt=""/></figure>
<h3>Reading entitlement data from Auth Custom Claims</h3>
<p>Before we get to the code, we need to understand a couple of concepts.</p>
<p><strong>What are custom claims, in a nutshell?</strong></p>
<p>A Firebase Auth user has the standard fields you’d expect from an authenticated user, depending on the authentication type: a UID, maybe an email, sign-in providers, etc. <a href="https://firebase.google.com/docs/auth/admin/custom-claims">Custom claims</a> let you add a small list of arbitrary key-value pairs on top of that, included directly into every ID token Firebase issues for the user. You can think of them as a tiny piece of trusted metadata that Firebase signs and ships with every request. Anything in the claims your backend can read and trust because Firebase has already verified the token’s signature.</p>
<p>This is exactly what the RevenueCat Extension handles for us. Because earlier, during installation, we enabled custom claims, the extension now writes a claim called revenueCatEntitlements onto each user: a simple list of the entitlement IDs that the user currently has active. So, for a Catflix subscriber, their ID token carries revenueCatEntitlements: [‘catflix Premium’]from now until something changes (a cancellation, a refund, the subscription period ending). The Extension keeps that claim in sync, the same way it keeps the Firestore document in sync.</p>
<p><em>Note: The entire </em><em><a href="https://firebase.google.com/docs/auth/admin/custom-claims#best_practices_for_custom_claims">custom-claims payload for a user is capped at 1KB</a></em><em>. That’s enough for an entitlements list, but worth knowing if you’re ever tempted to start stuffing user preferences in there: don’t.</em></p>
<p><strong>Where does the auth data come from in our function?</strong></p>
<p>Remember from earlier in the article that <strong>callable functions</strong> take care of a lot of heavy lifting for us: CORS, App Check verification, ID-token verification? One of the things they hand us “for free” is the verified auth payload in the request body via <code>request.auth</code>. By the time your handler runs, you already know who the caller is and, thanks to custom claims, what they’re allowed to do. No HTTP round-trip to RevenueCat, no Firestore read, nothing. The answer is right there in the request.</p>
<p>So our entire premium check collapses to: pull the revenueCatEntitlements claim out of the token and ask whether catflix Premium is in it.</p>
<pre><code class="language-javascript">// Sample in code: https:\/\/github.com\/darjaorlova\/catflix\/blob\/main\/functions\/lib\/play\/play.dart 
// Pseudocode: error handling and claim-decoding quirks omitted.
bool _hasCatflixPremiumInClaims(AuthData auth) {
  final entitlements = auth.token['revenueCatEntitlements'] as List?;
  return entitlements?.contains('catflix Premium') ?? false;
}</code></pre>
<p>That’s the whole thing. Notice this function isn’t async and doesn’t return a Future. There’s literally zero I/O. Compare that to the Firestore version, which still costs us a document read per call. With many requests, this difference can quickly add up.</p>
<p><strong>The catch: claim refresh latency</strong></p>
<p>There’s one tradeoff worth knowing about, otherwise you’ll hit it in production and have a confusing afternoon.</p>
<p>When the extension updates a custom claim on the server (e.g., the user just bought premium, cancelled, or their card got declined), the change is applied to the user’s auth record immediately. The catch is that custom claims live inside the user’s ID token, and the <a href="https://firebase.google.com/docs/auth/admin/manage-sessions">Firebase SDK on the client caches that token and reuses it for up to an hour</a> before refreshing it. So even though the server has the new claim, the client keeps sending the old token to your function, and your function reads whatever claims were baked into that old token.</p>
<p>The result: the user just paid for premium, but for up to an hour, the next call to your function still sees them as a free user. Which is fine for “the user cancelled, and we’re holding on to their access for a bit,” but very much not fine for “the user just paid and is staring at a still-locked play button.”</p>
<p>The fix is one line on the client. Whenever RevenueCat tells us that the user’s subscription has changed, we <a href="https://firebase.google.com/docs/auth/admin/custom-claims#propagate_custom_claims_to_the_client">force an ID-token refresh</a>:</p>
<pre><code class="language-javascript">// Sample in code: https:\/\/github.com\/darjaorlova\/catflix\/blob\/main\/lib\/catflix_repository.dart#L101 
// in your Flutter app
Purchases.addCustomerInfoUpdateListener((_) async {
  await FirebaseAuth.instance.currentUser?.getIdToken(true);
});
</code></pre>
<p>Now the very next call to our function carries the freshly-updated claim, and a just-purchased premium movie unlocks immediately.</p>
<p><em>Note: At the time of writing, the experimental Dart Cloud Functions SDK (firebase_functions: ^0.6.0) doesn’t expose custom claims on auth.token because they get stripped when the token is decoded. The pseudocode above is what your code will look like once that’s fixed; today, you have to grab the raw JWT off auth.rawToken and decode the payload yourself. Safe to do (the framework has already verified the token’s signature before your handler runs), but it’s a few lines of plumbing that have nothing to do with entitlements. TypeScript and Python SDKs surface claims correctly out of the box; this is a Dart-specific issue that should disappear in a future release.</em></p>
<p><strong>So which one should you pick?</strong></p>
<p>Both options end up at the same place: our function knows whether the user is premium without ever talking to RevenueCat at request time. The choice is mostly about latency vs freshness.</p>
<ul>
<li>Default to custom claims when the check runs on most requests. Zero I/O, zero marginal cost per call, scales for free as your user base grows.</li>
<li>Default to Firestore when you need entitlement updates to propagate within seconds (no token-refresh dance), or when your function already reads other Firestore data and one more read is essentially free.
</li>
</ul>
<p>Use both if you want. The Extension keeps them in sync, so there’s no source-of-truth conflict: you can use claims on hot paths and Firestore on admin/analytics paths in the same project.</p>
<h2>Summary</h2>
<p>Before we wrap this up, let’s reiterate a few important caveats and considerations.</p>
<ul>
<li>App Check is a must for production apps; this is the lowest-hanging fruit when it comes to Firebase security. Server-side entitlement checks tell your function whether to grant access; App Check tells it whether to even talk to the caller. Both layers do different jobs, so don’t drop App Check just because the entitlement check moved to the backend.</li>
<li>For Cloud Functions and RevenueCat Extension, you need to enable the Firebase pay-as-you-go Blaze plan. This means you will be billed, and make sure you secure your billing account and Firebase access.</li>
<li>Make sure your private and secret keys stay that way: don’t commit them to your repository, never expose them to your client, store securely, and rotate regularly.</li>
<li>If you’re using only Firebase Anonymous Authentication, remember that on re-install, you should restore your users’ purchases, because the UID is device-local.</li>
<li>If you rely on custom claims, remember to refresh the token on the client side when changes happen in the RevenueCat SDK.</li>
<li>Dart SDK for Firebase Cloud Functions is freshly launched and is currently behind the “experimental” flag: use with caution, read up on current limitations.</li>
</ul>
<p>We have gone through the best practices for building a simple, efficient, and secure tech stack to handle premium content access in your mobile apps. The main takeaway is timeless: never trust the client. Thankfully, if you’re using Firebase, the RevenueCat Firebase extension and Firebase Cloud Functions make this quite easy. Moreover, the newly launched Dart SDK for Firebase Cloud Functions makes the process even more streamlined (although in the era of Gen AI, the comfort of a familiar language becomes less important, and there are obvious benefits to more mature SDKs… yet I still love and prefer Dart).</p>
<p>Happy building, stay safe, and may the delighted users and pretty dollars be constant companions in your indie journey!</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[How to build a “name your price” paywall]]></title>
      <link>https://www.revenuecat.com/blog/engineering/how-to-build-a-name-your-price-paywall</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/how-to-build-a-name-your-price-paywall</guid>
      <pubDate>Mon, 08 Jun 2026 13:18:21 GMT</pubDate>
      <dc:creator><![CDATA[Perttu Lähteenlahti]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[A custom RevenueCat paywall that lets users choose their own price while unlocking the same premium entitlement.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/1f9f55dcce9a24a533fe513600c7353820bb7089-1600x800.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Paywalls don’t always have to follow the same model, in which customers choose between 2-3 subscription options (e.g., annual or yearly). Sometimes you might want something different. One option could be, for example, “name your price paywall”, where users can choose between differently priced packages, that all unlock the same entitlement:</p>
<p>This is a custom paywall that I built for my app Trippity, which I will be launching (hopefully soon). The reason I built it this way was to offer a single lifetime unlock for features while allowing customers to choose how much they want to support the app. The levels also appear in the app, so later on you can purchase a higher level if you feel like it.</p>
<p>This quick tutorial will look at how to organize your subscriptions to support a “name your price paywall” with five different subscription levels. Code examples are in React Native, but you could easily implement this in any development stack. The main setup takes place in the RevenueCat dashboard or, <a href="https://www.revenuecat.com/docs/tools/mcp">if you are using our MCP</a>, in your coding agent.</p>
<h3>Use the RevenueCat AI Toolkit to shorten development time</h3>
<p>If you’re using an AI coding assistant, you can skip most of the manual setup. <a href="https://www.revenuecat.com/blog/company/ai-toolkit/">The new RevenueCat AI Toolkit</a> lets agents like Claude Code, Codex, Gemini CLI, and VS Code create products, entitlements, offerings, and SDK integrations directly from prompts. </p>
<p>Install it with:</p>
<pre><code class="language-bash">claude plugins marketplace add RevenueCat/ai-toolkit

claude plugins install RevenueCat</code></pre>
<p>Or, for Codex:</p>
<pre><code class="language-bash">codex plugin marketplace add RevenueCat/ai-toolkit</code></pre>
<p>After authenticating with RevenueCat, you can simply ask your agent to create the products, entitlements, and offering needed for this tutorial. We’ll show both the AI-assisted and manual approaches below.</p>
<h2>How “name your price” slider paywall works</h2>
<p>The paywall slider lets users choose how much they feel the premium features are worth while still guiding them toward reasonable price anchors (e.g., $3.99 → $9.99). Because all price points unlock the same entitlement, the technical setup stays simple.</p>
<p>There are three parts to the system:</p>
<ol>
<li>Multiple IAP products in App Store Connect / Google Play Console
Example: support_399, support_599, support_999</li>
<li>A slider UI
Each slider position corresponds to a specific product ID</li>
<li>A single entitlement in RevenueCat</li>
</ol>
<p>No matter which price the user selects, they unlock the same features</p>
<p>This model works with both non-consumable in-app purchases as well as subscriptions (“Pay what feels right” kind of a thing). You could even combine these by making the lowest price a subscription. Just remember to keep it obvious what the total price and monthly price are so your app doesn’t get rejected by Apple or Google for deliberately confusing customers.</p>
<h2>Step 1: Create your price-tiered products and entitlement</h2>
<p>Our first step is to create the products we will be showing in our custom paywall. In this example, there are five products, with prices ranging from $1.99 to $19.99. I’ll show two ways of doing this: first, through RevenueCat MCP, and then through App Store Connect and importing those products to RevenueCat. The same approach works with the Google Play Store Console.</p>
<h3>
Create products using RevenueCat MCP</h3>
<p>Creating 5 different products using RevenueCat MCP is simple. First, see our setup guide for RevenueCat MCP here, and once that is done, run the following prompt in your MCP-connected agent of choice:</p>
<aside class="tip"><strong>Agent instructions</strong><p>Using RevenueCat MCP, create 5 non-consumable lifetime in-app purchases in both RevenueCat and App Store Connect. Create a single entitlement called Pro and connect all products to it.</p>
<p>Products:</p>
<p>* Pro Bronze Lifetime — $1.99
* Pro Silver Lifetime — $4.99
* Pro Gold Lifetime — $9.99
* Pro Platinum Lifetime — $14.99
* Pro Diamond Lifetime — $19.99</p>
<p>Requirements:</p>
<p>* All products unlock the same Pro entitlement permanently.
* Users can purchase any tier and receive lifetime Pro access.
* Users may later purchase a higher tier even if they already own a lower tier.
* Product IDs should follow the pattern: pro_bronze_lifetime, pro_silver_lifetime, etc.
* Ensure RevenueCat offerings and App Store Connect products are configured correctly and linked to the Pro entitlement.</p></aside>
<p>Once you run this prompt, you will be potentially asked for access to the MCP. As long as you’ve correctly set up the App Store Connect connection in RevenueCat, products should get created in both places. Alternatively, you can create these products in the RevenueCat Test store if you have not connected App Store Connect to RevenueCat.</p>
<h3>Create products using RevenueCat dashboard</h3>
<p>If you’d rather set everything up manually, start by creating your products in App Store Connect (or Google Play Console). Create one product for each price point and give them clear identifiers, such as:</p>
<ul>
<li>pro_bronze_lifetime — $1.99</li>
<li>pro_silver_lifetime — $4.99</li>
<li>pro_gold_lifetime — $9.99</li>
<li>pro_platinum_lifetime — $14.99</li>
<li>pro_diamond_lifetime — $19.99</li>
</ul>
<p>Since every purchase unlocks the same features, keep the product names and descriptions consistent. The only thing changing is the price. Once the products have been created in your store, import them into RevenueCat from <strong>Product Catalog → Products</strong>. <a href="https://www.revenuecat.com/docs/offerings/products-overview">RevenueCat can automatically import products from App Store Connect and Google Play after you’ve connected your stores. </a></p>
<p>Next, create a single entitlement that represents the premium access users receive. Navigate to <strong>Product Catalog → Entitlements</strong>, click <strong>New Entitlement</strong>, and create an entitlement called pro (or any identifier you prefer).</p>
<p>After creating the entitlement, open it and click <strong>Attach</strong> in the Products section. Select all five lifetime products and save. This tells RevenueCat that purchasing any price tier should unlock the same premium access.</p>
<p>Finally, create an Offering (for example, <strong>default</strong>) and add all five products to it. Your React Native app can then fetch the offering and display the products in the slider UI we’ll build next.</p>
<h2>Step 2: Connect RevenueCat to React Native</h2>
<p>Next, we need to add RevenueCat to our app. Once again, I’m going to give you both an agentic way to do this and instructions on how to do it by hand.</p>
<h3>Setup RevenueCat using an agent</h3>
<p>If you installed the RevenueCat AI Toolkit earlier, open your React Native project in your coding agent and ask it to wire up the SDK:</p>
<aside class="tip"><strong>Agent instructions</strong><p>Using the RevenueCat AI Toolkit, add RevenueCat to this React Native app.</p>
<p>Requirements:</p>
<p>- Install react-native-purchases</p>
<p>- Configure the SDK on app startup</p>
<p>- Use the correct public SDK key for iOS and Android</p>
<p>- Add any required native setup for iOS and Android</p>
<p>- Create a small purchases helper file for fetching offerings, checking the Pro entitlement, restoring purchases, and making purchases</p></aside>
<p>The agent should install the SDK, add the initialization code, and make sure the native project is configured correctly. You may be asked to authenticate with RevenueCat through OAuth before the agent can access your project.</p>
<h3>Set up RevenueCat manually</h3>
<p>Install the React Native SDK:</p>
<pre><code class="language-bash">npm install --save react-native-purchases</code></pre>
<p>Then configure RevenueCat when your app starts:</p>
<pre><code class="language-javascript">import { useEffect } from 'react';
import { Platform } from 'react-native';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';

export default function App() {
  useEffect(() =&gt; {
    Purchases.setLogLevel(LOG_LEVEL.DEBUG); // remove for release

    const apiKey = Platform.OS === 'ios'
      ? 'appl_YOUR_IOS_PUBLIC_SDK_KEY'
      : 'goog_YOUR_ANDROID_PUBLIC_SDK_KEY';

    Purchases.configure({ apiKey });
  }, []);

  return /* … your UI … */;
}</code></pre>
<p>You can find your public SDK keys in the RevenueCat dashboard under Project Settings → API Keys.</p>
<p>To check whether the user has unlocked your pro entitlement, fetch their CustomerInfo and use it in a custom hook:</p>
<pre><code class="language-javascript">import Purchases from 'react-native-purchases';

async function hasProAccess() {
  const customerInfo = await Purchases.getCustomerInfo();
  return customerInfo.entitlements.active.pro !== undefined;
}</code></pre>
<p>You can use this anywhere in your app to determine whether premium features should be unlocked. Since all five of our “name your price” products are attached to the same pro entitlement, it doesn’t matter which price tier the user purchased—RevenueCat will grant access in exactly the same way.</p>
<p>With RevenueCat connected and the entitlement configured, we’re ready to fetch our products and build the slider UI.</p>
<h2>Step 3: Fetch the available price tiers</h2>
<p>Now that RevenueCat is connected, we can fetch the products that will power the slider. Once again, you can either ask your coding agent to build this for you or implement it manually.</p>
<h3>Build the slider using an agent</h3>
<p>If you’re using the RevenueCat AI Toolkit, you can ask your agent:</p>
<aside class="tip"><strong>Agent instructions</strong><p>Build a React Native &quot;name your price&quot; paywall using RevenueCat.</p>
<p>Requirements:</p>
<p>- Fetch the current offering from RevenueCat</p>
<p>- Display all available packages as price tiers</p>
<p>- Sort the tiers from lowest to highest price</p>
<p>- Show a slider where each step maps to one package</p>
<p>- Show the selected price above the slider</p>
<p>- Purchase the selected package when the user taps Unlock</p>
<p>- After purchase, check whether the pro entitlement is active</p>
<p>- Include restore purchases</p></aside>
<p>That you should get to the stage where you have a slider. Prompt it to your liking to get the UI you want.</p>
<h3>Code for name your price paywall</h3>
<p>Here’s a pretty lengthy code sample that aims to replicate the slider flow you saw in the beginning:</p>
<pre><code class="language-javascript">import { useEffect, useMemo, useState } from 'react';
import {
  ActivityIndicator,
  Alert,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Slider from '@react-native-community/slider';
import Purchases, { PurchasesPackage } from 'react-native-purchases';

const ENTITLEMENT_ID = 'pro';

// Each slider position is a gem tier. All tiers map to the SAME entitlement —
// only the color, name, and product ID change.
type GemTier = {
  id: string;
  name: string;
  productIdentifier: string;
  color: string;
  gradient: [string, string]; // [light, dark] for the card + button
  tagline: string;
  level: number; // 1...5, drives the dot row
};

const SHARED_FEATURES = [
  'See data from all years',
  'Remove watermark from sharing',
  'Join the leaderboard',
];

const TIERS: GemTier[] = [
  { id: 'topaz',    name: 'Topaz',    productIdentifier: 'lifetime_topaz',    color: '#FFB233', gradient: ['#FFCC4D', '#FF991A'], tagline: 'Essential Explorer', level: 1 },
  { id: 'sapphire', name: 'Sapphire', productIdentifier: 'lifetime_sapphire', color: '#3366FF', gradient: ['#4D80FF', '#1A4DCC'], tagline: 'Sky Master',         level: 2 },
  { id: 'emerald',  name: 'Emerald',  productIdentifier: 'lifetime_emerald',  color: '#33CC66', gradient: ['#4DE680', '#1AB34D'], tagline: 'Eco Traveler',       level: 3 },
  { id: 'ruby',     name: 'Ruby',     productIdentifier: 'lifetime_ruby',     color: '#E63350', gradient: ['#FF4D66', '#CC1A33'], tagline: 'Premium Voyager',    level: 4 },
  { id: 'diamond',  name: 'Diamond',  productIdentifier: 'lifetime_diamond',  color: '#B3CCFF', gradient: ['#FFFFFF', '#99B3E6'], tagline: 'Ultimate Edition',   level: 5 },
];

export function NameYourPricePaywall() {
  const [selectedIndex, setSelectedIndex] = useState(2); // default to middle tier
  const [prices, setPrices] = useState&lt;Record&lt;string, string&gt;&gt;({});
  const [packages, setPackages] = useState&lt;PurchasesPackage[]&gt;([]);
  const [isPurchasing, setIsPurchasing] = useState(false);

  const selectedTier = TIERS[selectedIndex];
  const isLoaded = packages.length &gt; 0;

  useEffect(() =&gt; {
    (async () =&gt; {
      try {
        const offerings = await Purchases.getOfferings();
        const available = offerings.current?.availablePackages ?? [];
        setPackages(available);

        const priceMap: Record&lt;string, string&gt; = {};
        for (const pkg of available) {
          priceMap[pkg.product.identifier] = pkg.product.priceString;
        }
        setPrices(priceMap);
      } catch (e) {
        console.error('Failed to load offerings', e);
      }
    })();
  }, []);

  async function handlePurchase() {
    const pkg = packages.find(
      (p) =&gt; p.product.identifier === selectedTier.productIdentifier
    );
    if (!pkg) {
      Alert.alert('Unavailable', 'That tier is not available right now.');
      return;
    }

    setIsPurchasing(true);
    try {
      const { customerInfo } = await Purchases.purchasePackage(pkg);
      if (customerInfo.entitlements.active[ENTITLEMENT_ID]) {
        Alert.alert('Unlocked', `You're now a ${selectedTier.name} member.`);
      }
    } catch (e: any) {
      if (!e.userCancelled) {
        Alert.alert('Purchase failed', 'Please try again.');
      }
    } finally {
      setIsPurchasing(false);
    }
  }

  async function restorePurchases() {
    try {
      const customerInfo = await Purchases.restorePurchases();
      Alert.alert(
        customerInfo.entitlements.active[ENTITLEMENT_ID] ? 'Restored' : 'No purchases found',
        customerInfo.entitlements.active[ENTITLEMENT_ID]
          ? 'Your access has been restored.'
          : 'We could not find an active purchase.'
      );
    } catch (e) {
      console.error('Restore failed', e);
    }
  }

  return (
    // Background gradient tints toward the selected tier's color
    &lt;LinearGradient
      colors={['#FFFFFF', `${selectedTier.color}26`, `${selectedTier.color}4D`]}
      style={styles.container}
    &gt;
      &lt;ScrollView contentContainerStyle={styles.scroll}&gt;
        {/* Hero: frequent-flyer style membership card */}
        &lt;MembershipCard tier={selectedTier} /&gt;

        {/* Gem indicators on a connecting line */}
        &lt;GemRail selectedIndex={selectedIndex} onSelect={setSelectedIndex} /&gt;

        {/* Slider — each step is one tier */}
        &lt;Slider
          minimumValue={0}
          maximumValue={TIERS.length - 1}
          step={1}
          value={selectedIndex}
          onValueChange={(v) =&gt; setSelectedIndex(Math.round(v))}
          minimumTrackTintColor={selectedTier.color}
          thumbTintColor={selectedTier.color}
          style={styles.slider}
        /&gt;

        {/* Price */}
        &lt;View style={styles.priceBlock}&gt;
          {prices[selectedTier.productIdentifier] ? (
            &lt;&gt;
              &lt;View style={styles.priceRow}&gt;
                &lt;Text style={[styles.price, { color: selectedTier.color }]}&gt;
                  {prices[selectedTier.productIdentifier]}
                &lt;/Text&gt;
                &lt;Text style={[styles.priceTier, { color: selectedTier.color }]}&gt;
                  {selectedTier.name}
                &lt;/Text&gt;
              &lt;/View&gt;
              &lt;Text style={styles.priceCaption}&gt;
                One-time payment • Lifetime access
              &lt;/Text&gt;
            &lt;/&gt;
          ) : (
            &lt;ActivityIndicator /&gt;
          )}
        &lt;/View&gt;

        {/* Unlock button — gradient matches the tier */}
        &lt;Pressable
          onPress={handlePurchase}
          disabled={isPurchasing || !isLoaded}
          style={({ pressed }) =&gt; [{ opacity: pressed ? 0.9 : 1 }]}
        &gt;
          &lt;LinearGradient
            colors={selectedTier.gradient}
            start={{ x: 0, y: 0 }}
            end={{ x: 1, y: 1 }}
            style={styles.unlockButton}
          &gt;
            {isPurchasing ? (
              &lt;ActivityIndicator color=&quot;#fff&quot; /&gt;
            ) : (
              &lt;Text style={styles.unlockText}&gt;◆  Unlock {selectedTier.name}&lt;/Text&gt;
            )}
          &lt;/LinearGradient&gt;
        &lt;/Pressable&gt;

        &lt;Pressable onPress={restorePurchases} style={styles.restore}&gt;
          &lt;Text style={styles.restoreText}&gt;Restore Purchases&lt;/Text&gt;
        &lt;/Pressable&gt;

        &lt;Text style={styles.terms}&gt;
          By purchasing, you agree to our Terms of Service and Privacy Policy
        &lt;/Text&gt;
      &lt;/ScrollView&gt;
    &lt;/LinearGradient&gt;
  );
}

// MARK: – Membership card

function MembershipCard({ tier }: { tier: GemTier }) {
  return (
    &lt;LinearGradient
      colors={[`${tier.color}E6`, `${tier.color}B3`, `${tier.color}80`]}
      start={{ x: 0, y: 0 }}
      end={{ x: 1, y: 1 }}
      style={[styles.card, { shadowColor: tier.color }]}
    &gt;
      {/* Metallic sheen overlay */}
      &lt;LinearGradient
        colors={['rgba(255,255,255,0.12)', 'transparent', 'rgba(255,255,255,0.05)']}
        start={{ x: 0, y: 0 }}
        end={{ x: 1, y: 1 }}
        style={StyleSheet.absoluteFill}
      /&gt;

      {/* Header: brand + tier badge */}
      &lt;View style={styles.cardHeader}&gt;
        &lt;Text style={styles.brand}&gt;TRIPPITY&lt;/Text&gt;
        &lt;View style={styles.badge}&gt;
          &lt;Text style={styles.badgeText}&gt;◆ {tier.name.toUpperCase()}&lt;/Text&gt;
        &lt;/View&gt;
      &lt;/View&gt;

      &lt;Text style={styles.userName}&gt;Traveler&lt;/Text&gt;

      {/* Shared features */}
      &lt;View style={styles.features}&gt;
        {SHARED_FEATURES.map((f) =&gt; (
          &lt;Text key={f} style={styles.feature}&gt;✓  {f}&lt;/Text&gt;
        ))}
      &lt;/View&gt;

      {/* Footer: level dots + lifetime label */}
      &lt;View style={styles.cardFooter}&gt;
        &lt;View style={styles.dots}&gt;
          {[0, 1, 2, 3, 4].map((i) =&gt; (
            &lt;View
              key={i}
              style={[
                styles.dot,
                { backgroundColor: i &lt; tier.level ? '#fff' : 'rgba(255,255,255,0.3)' },
              ]}
            /&gt;
          ))}
        &lt;/View&gt;
        &lt;Text style={styles.lifetime}&gt;LIFETIME MEMBER&lt;/Text&gt;
      &lt;/View&gt;
    &lt;/LinearGradient&gt;
  );
}

// MARK: – Gem rail (indicators + connecting line)

function GemRail({
  selectedIndex,
  onSelect,
}: {
  selectedIndex: number;
  onSelect: (i: number) =&gt; void;
}) {
  return (
    &lt;View style={styles.rail}&gt;
      {TIERS.map((tier, i) =&gt; {
        const isActive = i &lt;= selectedIndex;
        const isSelected = i === selectedIndex;
        return (
          &lt;View key={tier.id} style={styles.railSegment}&gt;
            &lt;Pressable onPress={() =&gt; onSelect(i)} hitSlop={12}&gt;
              &lt;Text
                style={[
                  styles.gem,
                  {
                    fontSize: isSelected ? 26 : 16,
                    color: isActive ? tier.color : 'rgba(150,150,150,0.5)',
                    textShadowColor: isActive ? tier.color : 'transparent',
                    textShadowRadius: isSelected ? 10 : 0,
                  },
                ]}
              &gt;
                ◆
              &lt;/Text&gt;
            &lt;/Pressable&gt;
            {i &lt; TIERS.length - 1 &amp;&amp; (
              &lt;View
                style={[
                  styles.connector,
                  {
                    backgroundColor:
                      i &lt; selectedIndex ? TIERS[i + 1].color : 'rgba(150,150,150,0.3)',
                  },
                ]}
              /&gt;
            )}
          &lt;/View&gt;
        );
      })}
    &lt;/View&gt;
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  scroll: { padding: 24, gap: 20 },

  card: {
    height: 260,
    borderRadius: 16,
    padding: 20,
    justifyContent: 'space-between',
    overflow: 'hidden',
    shadowOpacity: 0.4,
    shadowRadius: 20,
    shadowOffset: { width: 0, height: 10 },
    elevation: 12,
  },
  cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  brand: { color: 'rgba(255,255,255,0.9)', fontSize: 14, fontWeight: '700', letterSpacing: 2 },
  badge: {
    flexDirection: 'row',
    backgroundColor: 'rgba(255,255,255,0.2)',
    borderColor: 'rgba(255,255,255,0.3)',
    borderWidth: 1,
    borderRadius: 100,
    paddingHorizontal: 10,
    paddingVertical: 4,
  },
  badgeText: { color: '#fff', fontSize: 11, fontWeight: '700', letterSpacing: 1 },
  userName: { color: '#fff', fontSize: 24, fontWeight: '700', marginTop: 10 },
  features: { gap: 6, marginTop: 8 },
  feature: { color: 'rgba(255,255,255,0.9)', fontSize: 12, fontWeight: '500' },
  cardFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  dots: { flexDirection: 'row', gap: 3 },
  dot: { width: 6, height: 6, borderRadius: 3 },
  lifetime: { color: 'rgba(255,255,255,0.6)', fontSize: 8, fontWeight: '500', letterSpacing: 1 },

  rail: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8 },
  railSegment: { flexDirection: 'row', alignItems: 'center', flex: 1 },
  gem: { textAlign: 'center', width: 30 },
  connector: { flex: 1, height: 3, borderRadius: 2 },

  slider: { marginHorizontal: 8 },

  priceBlock: { alignItems: 'center', gap: 4 },
  priceRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 6 },
  price: { fontSize: 36, fontWeight: '800' },
  priceTier: { fontSize: 18, fontWeight: '500', marginBottom: 4 },
  priceCaption: { fontSize: 12, color: '#888' },

  unlockButton: { borderRadius: 14, paddingVertical: 16, alignItems: 'center' },
  unlockText: { color: '#fff', fontSize: 16, fontWeight: '600' },

  restore: { alignItems: 'center', paddingTop: 4 },
  restoreText: { color: '#888', fontSize: 14 },
  terms: { color: '#aaa', fontSize: 11, textAlign: 'center', paddingHorizontal: 24, paddingBottom: 20 },
});</code></pre>
<p>This fetches your current RevenueCat Offering, sorts the available packages by price, and maps each package to a slider step. When the user taps Unlock Pro, the app purchases the currently selected package and then checks whether the pro entitlement is active.</p>
<p>Because every price tier unlocks the same entitlement, the app does not need separate access logic for Bronze, Silver, Gold, Platinum, or Diamond. The selected product only controls how much the user pays.</p>
<h2>Wrapping up</h2>
<p>A “name your price” paywall is surprisingly simple to implement. Under the hood, it’s just multiple products linked to the same entitlement, with a slider that lets users choose the level of support that feels right to them. The approach works especially well for indie apps, creator tools, open-source projects, and products where users genuinely want to support ongoing development. Instead of forcing customers into a single price point, you give them flexibility while keeping your purchase logic and entitlement management simple.</p>
<p>Because everything is powered by RevenueCat Offerings, you can also experiment over time. Add or remove price tiers, change your default recommendation, or test entirely different pricing strategies without rebuilding the underlying purchase flow.</p>
<p>If you build one, I’d love to see it. Tag me on Twitter at <a href="http://x.com/plahteenlahti">@plahteenlahti</a> and let me know what pricing tiers you decided to offer.</p>
<h3>Sources</h3>
<ul>
<li><a href="https://github.com/RevenueCat/ai-toolkit">RevenueCat AI Toolkit</a></li>
<li><a href="https://www.revenuecat.com/docs/getting-started/installation/reactnative">RevenueCat React Native SDK installation</a></li>
<li><a href="https://www.revenuecat.com/docs/getting-started/entitlements">RevenueCat entitlements</a></li>
<li><a href="https://www.revenuecat.com/docs/projects/configuring-products">RevenueCat offerings and product configuration</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Migrating native BillingClient and StoreKit code to shared Kotlin Multiplatform in-app purchases]]></title>
      <link>https://www.revenuecat.com/blog/engineering/kmp-migration</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/kmp-migration</guid>
      <pubDate>Mon, 08 Jun 2026 03:06:25 GMT</pubDate>
      <dc:creator><![CDATA[Jaewoong Eum]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[In this article, you'll add the RevenueCat SDK to an existing project, replace two platform initializers with one commonMain IAP configuration.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/26602616b03019378c8acccbd55cad13a74f35fc-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Most teams that adopt Kotlin Multiplatform share their networking, models, and business logic first, and leave in-app purchases for last. Billing is the one layer where Android and iOS share almost nothing: a <code>BillingClient</code> connection and a <code>PurchasesUpdatedListener</code> on one side, a StoreKit transaction listener and manual receipt verification on the other, with two server-side validation paths behind them that have to agree on what “subscribed” means.</p>
<p>Migrating that to a shared layer is less about rewriting Kotlin and more about replacing two purchase state machines with one. This article walks through collapsing both native billing stacks into a single <code>commonMain</code> integration backed by RevenueCat’s <a href="https://github.com/RevenueCat/purchases-kmp">Kotlin Multiplatform SDK</a>, without losing the customers who already paid through your existing code.</p>
<p>In this article, you’ll add the RevenueCat SDK to an existing project, replace two platform initializers with one <code>commonMain</code> configuration, migrate product loading, the purchase flow, and entitlement checks, bring existing purchasers across with <code>syncPurchases</code>, keep user identity stable through <code>logIn</code>, and verify parity before you delete the old <code>BillingClient</code> and StoreKit code.</p>
<h2><strong>The fundamental problem: two billing stacks that share no types</strong></h2>
<p>The reason billing resists code sharing is that the two platforms model a purchase differently at every step, and the types never line up.</p>
<p>On Android, you own a connection. You build a <code>BillingClient</code>, register a <code>PurchasesUpdatedListener</code>, start the connection, and handle reconnection when the service drops. The result of a purchase arrives asynchronously on the listener, not as a return value:</p>
<pre><code class="language-kotlin">private val purchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -&gt;
    if (result.responseCode == BillingResponseCode.OK &amp;&amp; purchases != null) {
        purchases.forEach { handlePurchase(it) }
    } else if (result.responseCode == BillingResponseCode.USER_CANCELED) {
        \/\/ back out quietly
    }
}

private val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
    .build()</code></pre>
<p>After the buyer confirms, you are not done. You verify the purchase token on your server against the Google Play Developer API, grant the entitlement, and then acknowledge the purchase. If you fail to acknowledge within three days, Google Play automatically refunds the user and revokes the purchase:</p>
<pre><code class="language-kotlin">private fun handlePurchase(purchase: Purchase) {
    if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED &amp;&amp; !purchase.isAcknowledged) {
        \/\/ 1. verify purchase.purchaseToken on your backend
        \/\/ 2. grant the entitlement
        val params = AcknowledgePurchaseParams.newBuilder()
            .setPurchaseToken(purchase.purchaseToken)
            .build()
        billingClient.acknowledgePurchase(params) { \/* ack result *\/ }
    }
}</code></pre>
<p>On iOS, the shape is different. There is no long-lived connection, but there is a verification step you cannot skip, and you finish each transaction by hand:</p>
<pre><code class="language-kotlin">let result = try await product.purchase()
switch result {
case .success(let verification):
    let transaction = try checkVerified(verification) \/\/ validate the signed JWS
    await grantEntitlement(for: transaction)
    await transaction.finish()
case .userCancelled, .pending:
    break
@unknown default:
    break
}</code></pre>
<p>Reading the current entitlement state is two unrelated APIs. Android queries owned purchases and you map purchase tokens back to entitlements yourself. iOS iterates <code>Transaction.currentEntitlements</code> and you verify each one. The objects are different, the verification model is different, and the field that tells you “this user has premium” does not exist in either SDK. You compute it.</p>
<p>Underneath both clients, you typically run two receipt validation paths, because a Google purchase token and an App Store signed transaction are validated against different servers and stored in different shapes. Every concept in your subscription logic exists twice, and the two copies drift.</p>
<h2><strong>The shared model that replaces both</strong></h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/0beab2b4a80b49345aad4f63a6cb774c0ec5afbe-1880x1016.png" alt=""/></figure>
<p>RevenueCat’s KMP SDK does not expose <code>BillingClient</code> or StoreKit through a thin shim. It replaces both with four store-agnostic concepts that live in <code>commonMain</code>:</p>
<ul>
<li><strong>Offerings</strong> are the set of products you currently sell, configured in the dashboard rather than hardcoded in the app.</li>
<li><strong>Packages</strong> are the buyable units inside an offering, such as monthly, annual, or lifetime.</li>
<li><strong>Entitlements</strong> are the access levels your app checks, such as <code>premium</code>, independent of which store the user paid through.</li>
<li><strong>CustomerInfo</strong> is one object that aggregates every active entitlement for the current user across platforms.</li>
</ul>
<p>Once your code asks <code>customerInfo.entitlements[&quot;premium&quot;]?.isActive</code> instead of “did this user buy token X on Google or sign transaction Y on Apple,” the platform split disappears from your app. The migration is mostly a matter of deleting the per platform machinery and routing each old call to its shared equivalent:</p>
<table>
<thead><tr>
<th><p>Native Android / iOS</p></th>
<th><p>Shared <code>commonMain</code> call</p></th>
</tr></thead>
<tbody>
<tr>
<td><p><code>BillingClient.startConnection</code> / StoreKit listener setup</p></td>
<td><p><code>Purchases.configure(apiKey) { }</code></p></td>
</tr>
<tr>
<td><p><code>queryProductDetailsAsync</code> / <code>Product.products(for:)</code></p></td>
<td><p><code>awaitOfferings()</code> or <code>awaitGetProducts(ids)</code></p></td>
</tr>
<tr>
<td><p><code>launchBillingFlow</code> + <code>onPurchasesUpdated</code> / <code>product.purchase()</code></p></td>
<td><p><code>awaitPurchase(package)</code></p></td>
</tr>
<tr>
<td><p>acknowledge / <code>transaction.finish()</code></p></td>
<td><p>handled by the SDK</p></td>
</tr>
<tr>
<td><p>server side receipt validation</p></td>
<td><p>handled by RevenueCat’s backend</p></td>
</tr>
<tr>
<td><p><code>queryPurchasesAsync</code> / <code>Transaction.currentEntitlements</code></p></td>
<td><p><code>awaitCustomerInfo()</code></p></td>
</tr>
<tr>
<td><p>restore button / <code>AppStore.sync()</code></p></td>
<td><p><code>awaitRestore()</code></p></td>
</tr>
<tr>
<td><p>importing existing purchasers</p></td>
<td><p><code>awaitSyncPurchases()</code></p></td>
</tr>
</tbody>
</table>
<p>The rest of this article walks through each row from top to bottom.</p>
<h2><strong>What the SDK does at the platform boundary</strong></h2>
<p>Before changing your code, it helps to see that the shared API is not magic. The translation from native types to KMP types is an explicit boundary that you can read in the SDK’s <code>mappings</code> module, and seeing it removes the worry that something is hidden.</p>
<p><code>StoreProduct</code> is a good example, because Google’s <code>ProductDetails</code> and StoreKit’s product type have nothing in common. On Android, the mapping wraps the native object and reads its Kotlin properties directly (simplified here to the fields that matter for the contrast):</p>
<pre><code class="language-kotlin">public fun NativeAndroidStoreProduct.toStoreProduct(): StoreProduct = AndroidStoreProduct(this)

private class AndroidStoreProduct(val wrapped: NativeAndroidStoreProduct) : StoreProduct {
    override val id: String = wrapped.id
    override val title: String = wrapped.title
    override val localizedDescription: String = wrapped.description
    override val price: Price = wrapped.price.toPrice()
    override val subscriptionOptions: SubscriptionOptions? = wrapped.subscriptionOptions?.toSubscriptionOptions()
    override val discounts: List&lt;StoreProductDiscount&gt; = emptyList()
    override val introductoryDiscount: StoreProductDiscount? = null
}</code></pre>
<p>The iOS mapping, also simplified, implements the same StoreProduct interface, but the native value is reached through Objective C bridged method calls rather than properties, and the platform specific fields flip:</p>
<pre><code class="language-kotlin">public fun NativeIosStoreProduct.toStoreProduct(): StoreProduct = IosStoreProduct(this)

private class IosStoreProduct(val wrapped: NativeIosStoreProduct) : StoreProduct {
    override val id: String = wrapped.productIdentifier()
    override val title: String = wrapped.localizedTitle()
    override val localizedDescription: String = wrapped.localizedDescription()
    override val price: Price = wrapped.toPrice()
    override val subscriptionOptions: SubscriptionOptions? = null
    override val discounts: List&lt;StoreProductDiscount&gt; =
        wrapped.discounts().map { (it as IosStoreProductDiscount).toStoreProductDiscount() }
}</code></pre>
<p>Notice the two design decisions that make one interface work on both stores. First, access is <code>wrapped.id</code> on Android but <code>wrapped.productIdentifier()</code> on iOS, because the Android side wraps the native RevenueCat Android model while the iOS side wraps a StoreKit backed value reached through Kotlin/Native interop. Second, fields that only exist on one platform are filled with safe defaults on the other: <code>subscriptionOptions</code> is a Play Store concept, so it is <code>null</code> on iOS, and <code>discounts</code> is an App Store concept, so it is <code>emptyList()</code> on Android. Your common code reads a single <code>StoreProduct</code> and never branches on the platform.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/8c6e382cd3a407ff719486a367bb030341bb28e7-1160x622.png" alt=""/></figure>
<p>This is the boundary you are migrating onto. Everything below replaces a native call with a call into this shared model.</p>
<h2><strong>Step 1: Add the SDK and drop the native billing dependencies</strong></h2>
<p>Add the SDK to your shared module’s <code>commonMain</code> source set. The core artifact carries the platform bindings inside it, so there is no separate <code>androidMain</code> dependency on Play Billing and no iOS pod:</p>
<pre><code class="language-kotlin">kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(&quot;com.revenuecat.purchases:purchases-kmp-core:3.0.3&quot;)
            \/\/ Optional: the Compose Multiplatform paywall component
            implementation(&quot;com.revenuecat.purchases:purchases-kmp-ui:3.0.3&quot;)
        }
    }
}</code></pre>
<p>If you are coming from the 2.x line, the iOS side changed in a way that matters here. In 2.x you pinned a <code>PurchasesHybridCommon</code> pod and kept its version in sync with the SDK. As of 3.0.0 the iOS native dependency is pulled through Gradle as a Swift Package Manager build of <code>purchases-ios</code>, so a consumer app has no <code>Podfile</code>, no <code>PurchasesHybridCommon</code>, and nothing to pin. The Kotlin framework your shared module produces already contains the RevenueCat symbols. The same release raises the Android floor to API 23 (Android 6.0) because it ships on Play Billing Library 8.3.0, so set <code>minSdk = 23</code> if you are lower.</p>
<p>You do not delete your <code>BillingClient</code> and StoreKit code yet. You will route around it step by step, verify parity, and remove it at the end.</p>
<h2><strong>Step 2: Replace two initializers with one</strong></h2>
<p>Native initialization is two separate jobs. On Android, you build and connect a <code>BillingClient</code> and hold its connection state. On iOS, you register a StoreKit transaction observer early in the app lifecycle so you do not miss updates. Neither has a shared form, so they live in <code>androidMain</code> and Swift respectively.</p>
<p>With the SDK, configuration is one call in <code>commonMain</code> that runs once at startup:</p>
<pre><code class="language-kotlin">Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(apiKey = revenueCatApiKey) {
    appUserId = null \/\/ stay anonymous until the user logs in
}</code></pre>
<p>The <code>configure(apiKey) { }</code> form is a small builder. Inside the trailing lambda you can set <code>appUserId</code>, <code>purchasesAreCompletedBy</code>, and the other options on <code>PurchasesConfiguration.Builder</code>. The API key is the one value that differs per platform, so inject it with <code>expect</code>/<code>actual</code> or a tool like BuildKonfig, using your Android key on the Android target and your iOS key on the iOS targets.</p>
<p>Two things you used to manage are now gone. There is no connection to open and reconnect: the SDK owns the <code>BillingClient</code> lifecycle on Android and the StoreKit transaction listener on iOS. And there is no <code>Context</code> to thread through on Android, because the SDK captures the <code>Application</code> through an <code>androidx.startup</code> initializer. On iOS, nothing in your Swift code imports RevenueCat at all. The same <code>configure</code> call covers both platforms.</p>
<h2><strong>Step 3: Replace product loading</strong></h2>
<p>Loading products natively means describing each product to the store SDK and parsing back a platform-specific response. On Android, you build a <code>QueryProductDetailsParams</code>, set a product type, and parse the <code>QueryProductDetailsResult</code> that the Play Billing 8 callback returns, reading <code>subscriptionOfferDetails</code> and pricing phases off each <code>ProductDetails</code>. On iOS, you call <code>Product.products(for:)</code> with a set of identifiers. The two responses share no type.</p>
<p>The shared replacement reads the offering you configured in the dashboard:</p>
<pre><code class="language-kotlin">val offerings = Purchases.sharedInstance.awaitOfferings()
val premium = offerings.current?.monthly
    ?: offerings.current?.availablePackages?.firstOrNull()
    ?: error(&quot;No current offering configured&quot;)</code></pre>
<p><code>Offerings.current</code> is the offering marked as current in the dashboard. <code>Offering</code> exposes named accessors for common cadences such as <code>monthly</code>, <code>annual</code>, and <code>lifetime</code>, plus <code>availablePackages</code> if you build a custom plan picker. Because the product list comes from the dashboard, you can change which products an app version sells without shipping a build, and the prices arrive already localized for the user’s storefront.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/2e641098f9b188be4abc0c0e8843b0e5e7094c4e-1860x450.png" alt=""/></figure>
<p>If you are not using offerings yet and want a direct lookup that mirrors your old <code>queryProductDetailsAsync</code> call, <code>awaitGetProducts(productIds)</code> takes a list of identifiers and returns <code>List&lt;StoreProduct&gt;</code>. Both methods are <code>suspend</code> functions, so they replace the listener and async callback with a straight line call.</p>
<h2><strong>Step 4: Replace the purchase flow</strong></h2>
<p>This is the step that removes the most code. Natively, a purchase is a multi stage process you orchestrate by hand. On Android, you build <code>BillingFlowParams</code>, call <code>launchBillingFlow</code>, wait for the result on <code>onPurchasesUpdated</code>, check <code>PurchaseState</code>, verify the token on your server, grant the entitlement, and acknowledge within three days. On iOS you call <code>product.purchase()</code>, switch on the result, verify the signed transaction, grant the entitlement, and call <code>transaction.finish()</code>.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/a119216e7b84fa69b87dbe7edadb4ee80207c6a7-1620x778.png" alt=""/></figure>
<p>The shared replacement is one suspend call:</p>
<pre><code class="language-kotlin">try {
    val purchase = Purchases.sharedInstance.awaitPurchase(premium)
    val isPremium = purchase.customerInfo.entitlements[&quot;premium&quot;]?.isActive == true
    \/\/ unlock content based on isPremium
} catch (e: PurchasesTransactionException) {
    if (e.userCancelled) {
        \/\/ user dismissed the dialog, treat as a no op
    } else {
        \/\/ surface e.error
    }
}</code></pre>
<p><code>awaitPurchase</code> opens the native purchase dialog, validates the receipt on RevenueCat’s backend, finishes the transaction on the store, and returns a <code>SuccessfulPurchase</code>. Read the result through its properties, <code>purchase.storeTransaction</code> and <code>purchase.customerInfo</code>, since it is a plain class rather than a destructurable data class. By the time the call resumes, the receipt is validated and the user’s <code>CustomerInfo</code> already reflects the new entitlement, so anything bound to your customer info state updates on its own.</p>
<p>Two behaviors from the native flow are now handled for you. Acknowledgement and finishing happen inside the SDK, which means the three day Google Play refund trap is closed by default. Cancellation is surfaced as a <code>PurchasesTransactionException</code> whose <code>userCancelled</code> flag is true, so you can distinguish a buyer backing out from a genuine error in one <code>catch</code>.</p>
<p>There is one nuance worth keeping if you sell consumables. Play Billing Library 8 removed the ability to query already consumed one time products, the APIs the SDK previously relied on to restore them, so a consumable that was consumed cannot be reconstructed on the client. Grant those entitlements server side, through your backend or RevenueCat’s grant API, rather than relying on a client restore. Subscriptions and non consumable products are not affected.</p>
<h2><strong>Step 5: Replace entitlement checks</strong></h2>
<p>Natively, “is this user subscribed” is a computation. On Android you call <code>queryPurchasesAsync</code>, walk the returned purchases, and map each token to one of your entitlements. On iOS you iterate <code>Transaction.currentEntitlements</code>, verify each transaction, and map product identifiers to entitlements. Both produce your own boolean.</p>
<p>The shared form is a property read:</p>
<pre><code class="language-kotlin">val info = Purchases.sharedInstance.awaitCustomerInfo()
val isPremium = info.entitlements[&quot;premium&quot;]?.isActive == true</code></pre>
<p><code>CustomerInfo.entitlements</code> is an <code>EntitlementInfos</code> wrapper, and its indexing operator returns a nullable <code>EntitlementInfo</code>, which is why the null safe <code>?.isActive == true</code> is the correct check. The <code>&quot;premium&quot;</code> key is the entitlement identifier you set up in the dashboard, not a product id. When you need more than a boolean, <code>EntitlementInfo</code> carries the fields you previously reconstructed yourself:</p>
<ul>
<li><code><strong>isActive</strong></code> is the only field most access decisions need.</li>
<li><code><strong>willRenew</strong></code> tells you whether the subscription is set to renew, which you cannot reliably derive from a purchase token.</li>
<li><code><strong>store</strong></code> reports where the entitlement was unlocked, such as <code>PLAY_STORE</code> or <code>APP_STORE</code>.</li>
<li><code><strong>periodType</strong></code> distinguishes a <code>TRIAL</code> or <code>INTRO</code> period from a <code>NORMAL</code> one.</li>
<li><code><strong>expirationDate</strong></code> is the entitlement’s expiry, or <code>null</code> for lifetime access.</li>
</ul>
<p>The value is active whether the user subscribed through Google Play, the App Store, or a promotional grant from your support team, which is the property that lets you delete the per store mapping code on both platforms.</p>
<h2><strong>Step 6: Bring your existing purchasers with you</strong></h2>
<p>A migration is not a fresh install. Your existing customers already paid through your old native code, and RevenueCat does not know about those purchases yet. Skipping this step is how a migration silently locks out paying users, so treat it as part of the cutover rather than a follow-up.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/01f10df7527315e613c81759f408c6eb0cdd4288-1962x846.png" alt=""/></figure>
<p>For users whose purchases live on the store account, call syncPurchases once after configuring. Its KDoc states the intent directly:</p>
<pre><code class="language-kotlin">\/**
 * This method will send all the purchases to the RevenueCat backend. Call this when using your own
 * implementation for subscriptions anytime a sync is needed, such as when migrating existing users
 * to RevenueCat.
 *
 * Warning: This function should only be called if you're migrating to RevenueCat or in observer
 * mode.
 *\/
public suspend fun Purchases.awaitSyncPurchases(): CustomerInfo</code></pre>
<p>This posts the store receipts already on the device to RevenueCat, which reconstructs the customer’s entitlement state from them. It is a migration and observer mode tool, not something you call on every launch.</p>
<p>If you want to de risk the cutover, run the SDK in observer mode first by setting <code>purchasesAreCompletedBy</code> to <code>MyApp</code>. In this mode your existing billing code keeps completing purchases while RevenueCat observes and builds its view of your customers, so you can compare its entitlement data against your current source of truth before you flip the switch:</p>
<pre><code class="language-kotlin">Purchases.configure(apiKey = revenueCatApiKey) {
    purchasesAreCompletedBy = PurchasesAreCompletedBy.MyApp(StoreKitVersion.STOREKIT_2)
}</code></pre>
<p>Note the trade-off the SDK calls out in the same configuration: when <code>purchasesAreCompletedBy</code> is <code>MyApp</code> on Android, you are still responsible for acknowledging purchases yourself, and failing to acknowledge within three days lets Google Play refund the user and revoke the purchase. Observer mode is a staging step on the way to letting RevenueCat complete purchases, not a permanent home.</p>
<h2><strong>Step 7: Keep user identity stable</strong></h2>
<p>The last piece is identity, because losing it is the other way a migration drops entitlements. If you left <code>appUserId</code> null, the SDK uses an anonymous identity it generates and persists. The moment you can tie purchases to your own account system, identify the user so their entitlements follow them across devices and reinstalls:</p>
<pre><code class="language-kotlin">val login = Purchases.sharedInstance.awaitLogIn(&quot;your-stable-user-id&quot;)
val isPremium = login.customerInfo.entitlements[&quot;premium&quot;]?.isActive == true
\/\/ login.created is true if this registered a new backend user</code></pre>
<p><code>awaitLogIn</code> returns a <code>SuccessfulLogin</code> with the merged <code>customerInfo</code> and a <code>created</code> flag.</p>
<p>RevenueCat maintains the alias graph between the anonymous identity and the identified one on its backend, so purchases made before login transfer to the account. On logout, <code>awaitLogOut</code> clears the saved id and returns to a fresh anonymous user.</p>
<p>This also clarifies when to use restore versus login. <code>awaitRestore</code> posts the purchases on the current store account to the current user, which is what Apple’s required restore button should call. Apple mandates a visible restore control regardless of whether you have your own account system, so that button stays even when <code>logIn</code> already covers continuity for you.</p>
<p>The SDK’s own guidance is that if you have your own account system, you do not need restore for continuity, because “restoration” is simply your app passing the same <code>appUserID</code> the customer used when they first purchased. In practice, rely on <code>logIn</code> for account based continuity and keep the restore entry point for store account based recovery.</p>
<h2><strong>Verify parity before you delete anything</strong></h2>
<p>The native code stays until you have proven the shared path matches it. Run this checklist on a sandbox account on both platforms before removing <code>BillingClient</code> and StoreKit code:</p>
<ol>
<li><strong>Products load</strong> correctly from <code>awaitOfferings</code> on both platforms with correct localized prices.</li>
<li><strong>A purchase completes</strong> end to end through <code>awaitPurchase</code>, and the entitlement flips to active without your old acknowledgement or <code>finish</code> code running.</li>
<li><strong>An upgrade or downgrade</strong> between plans works on Android with the right <code>oldProductId</code> and replacement mode, since that path is the most likely to regress when you remove the native <code>BillingFlowParams</code> code.</li>
<li><strong>A pending purchase</strong>, such as a deferred cash payment, resolves to an active entitlement, matching the <code>enablePendingPurchases</code> behavior your native code used to handle.</li>
<li><strong>Cancellation</strong> surfaces as <code>PurchasesTransactionException</code> with <code>userCancelled</code> true, not as an error.</li>
<li><strong>Trial and introductory state</strong> renders correctly through <code>periodType</code>, remembering that eligibility itself is iOS only.</li>
<li><strong>Existing purchasers</strong> see their entitlements after <code>syncPurchases</code> or <code>restore</code>, including a reinstall test.</li>
<li><strong>Identity merges</strong> correctly: a purchase made anonymously appears under the account after <code>logIn</code>.</li>
</ol>
<p>Once those hold, delete the <code>BillingClient</code> setup, the <code>PurchasesUpdatedListener</code>, the acknowledgement code, the StoreKit purchase and verification code, and the <code>com.android.billingclient:billing</code> dependency. If you ran observer mode, you can also retire the parts of your receipt validation server that the RevenueCat backend now covers, or keep them running in parallel until you are confident.</p>
<h2><strong>What still needs platform awareness</strong></h2>
<p>The shared model covers the common path, but a few capabilities remain tied to one store, and it is better to know which than to assume the abstraction hides everything:</p>
<ul>
<li><strong>Introductory and trial eligibility</strong> are iOS-specific. <code>awaitTrialOrIntroPriceEligibility</code> returns a real status on iOS and <code>UNKNOWN</code> on Android, where Google computes eligibility itself.</li>
<li><strong>Win back offers</strong> require iOS 18 and StoreKit 2, and the related calls throw on other configurations.</li>
<li><strong>Personalized price and replacement mode</strong> for upgrades are Play Store only parameters on <code>awaitPurchase</code>, ignored on iOS.</li>
<li><strong>Amazon Appstore</strong> support is opt-in through a separate <code>purchases-store-amazon</code> dependency on the Android source set.</li>
</ul>
<p>These stay as small, well-marked branches in otherwise shared code, rather than the two full billing implementations you started with.</p>
<h2><strong>Conclusion</strong></h2>
<p>Migrating to a shared in-app purchases layer is mostly subtraction. You replace two initializers with one <code>configure</code>, two product queries with <code>awaitOfferings</code>, two purchase pipelines with <code>awaitPurchase</code>, and two entitlement computations with one <code>entitlements[&quot;premium&quot;].isActive</code> check, then bring your existing customers across with <code>syncPurchases</code> and <code>logIn</code> before deleting the native code. What remains is one purchase flow to reason about instead of two.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Why are React Native apps making more money?]]></title>
      <link>https://www.revenuecat.com/blog/engineering/why-react-native-apps-make-more-money</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/why-react-native-apps-make-more-money</guid>
      <pubDate>Wed, 03 Jun 2026 15:02:21 GMT</pubDate>
      <dc:creator><![CDATA[Perttu Lähteenlahti]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[Why React Native apps beats Flutter and Native apps in revenue]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/e8586a21bbaf8ec935ee633342636480bf322bbd-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Earlier this year, we released the newest version of our <a href="https://www.revenuecat.com/state-of-subscription-apps/">annual State of Subscription apps report</a> (SOSA), which contains a huge amount of interesting insights for app builders. One of these insights was how well apps with different development stacks (also referred to as frameworks in the report) are performing when it comes to monetization.</p>
<p>This one insight turned out to be rather controversial, despite the previous year’s data showing similar results: React native apps are monetizing better than Flutter and native apps. Specifically, they are excelling in:</p>
<ul>
<li>Higher conversion from download to paid</li>
<li>Higher revenue per install</li>
<li>Higher lifetime value</li>
</ul>
<p>When I <a href="https://x.com/plahteenlahti/status/2029961382121050527">tweeted about it</a>, some developers were quick to question the results, the dataset, correlation vs causation, and essentially everything that goes against the “intuition” that technology is the key to building great apps that customers want to pay for. This article is about looking at the data and figuring out the why.</p>
<h2>How is this data collected?</h2>
<p>The data for the State of Subscription Apps report is collected from 115,000 apps, making a combined $16 billion in revenue. RevenueCat tracks more than the mentioned amount of apps, but only apps that have active subscription revenue, meet a minimum threshold of installs and revenue (to ensure statistically meaningful findings) are included in the report. Apps come from indie teams to mid-size organizations and large publishers.</p>
<p>The time frame for the data is 2025. This means that the data set includes apps built with agents and vibe coding tools. All data is anonymized and aggregated, ensuring that no single app’s performance metrics are individually identifiable. The findings are presented as aggregated performance benchmarks across segments, categories, and platforms.</p>
<h3>Understanding the visualizations</h3>
<p>Before diving into what the numbers tell us about React Native, Flutter, and native apps, it’s important to understand the structure of the SOSA dataset and how its visualizations work, since I’ll be showing them in this article.</p>
<p>The report uses box-and-whisker (candlestick) charts to visualize the data:</p>
<ul>
<li><strong>Lower ‘whisker’: marks P10 (10th percentile), </strong>the bottom 10% of app performance.</li>
<li>Bottom of the ‘box’: marks P25 (25th percentile) and represents Q1 (bottom quartile) — apps below this make up the lowest 25% of performers.</li>
<li><strong>Marker inside the ‘box’: </strong>marks P50 (50th percentile) and represents Q2 (median) — this is the midpoint of app performance.</li>
<li><strong>Top of the ‘box’:</strong> marks P75 (75th percentile) and represents Q3 (upper quartile) — apps above this fall into the top 25% of performers.</li>
<li><strong>Upper ‘whisker’: </strong>marks P90 (90th percentile) — apps here or above are the highest performers.</li>
</ul>
<p>This structure gives a more realistic picture of how apps perform in the real world. The “box” shows where most apps cluster, while the whiskers show the spread between weaker and exceptional performers. When we talk about React Native outperforming other stacks, we’re referring to these distribution points — not isolated outliers.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/36924ba2c6de89fa3ca868cec22bb1be3da352af-2040x996.png" alt=""/></figure>
<h2>React Native leads in download to paid conversion</h2>
<p>The first chart to look at is the Download-to-paid conversion rate (D35): the share of installs that result in at least one paid subscription within 35 days of the install date. This metric captures how effectively an app converts new users into paying customers in the first ~5 weeks.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/fbb5eb14baf17cccaccc7ef7180b5d8eae06b21a-1596x1148.png" alt=""/></figure>
<p>The first thing you should notice from this chart is that React Native apps have the highest median conversion rate at 2.5%. That might not sound like much, but it is still 25% higher than native and Flutter. However, the absolute spread between stacks is modest compared to within-stack variation.</p>
<p>After that, pay attention to the top whisker of React Native. Being higher than others means that top React Native apps are outperforming other stacks; at the same time, the results are very widespread. Some React Native apps do great, others don’t.</p>
<h3>First key learning: execution matters far more than stack choice</h3>
<p>If you look at the spread inside each stack, you start to get a picture of the real insight in this data: the variance within a stack absolutely dwarfs the variance between stacks.</p>
<p>Put another way, the top performers convert to paying customers 10+ times better than the bottom performers in their own stack. Execution matters more than the stack itself.</p>
<p>What’s actually pulling the top React Native apps ahead? Probably not the stack itself. More likely, it’s the teams building them. These teams have to focus on two things to win: building a great app and monetizing it effectively. Stack choice can enable you to do that, but it is not a magic spell that doubles your conversion.</p>
<h2>React Native apps generate more revenue per install</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/f71c34098c976e7b0ba8199830736b092fcacbc4-1496x1148.png" alt=""/></figure>
<p>The second chart to look at is the revenue per install (RPI) chart. This chart tells us how much revenue an app makes 14 days after a user installs it. From the chart, we can see that React native leads with a median of $0.34, which is 55% higher than native and 79% higher than Flutter. The top quartile of React Native apps is also making more money than the top quartile of Native or Flutter apps.</p>
<p>Despite React Native’s numbers looking good, there is a huge difference between apps using the same stack. Some apps are only making $0.10 per install, compared to some making $2.58. Similar patterns exist across every stack. The difference between top-performing and bottom-performing apps is far larger than the difference between the stacks themselves.</p>
<h3>Second key learning: feedback loops</h3>
<p>Stack choice appears to have a relationship with monetization outcomes. One possible explanation is that successful subscription businesses tend to optimize relentlessly based on customer feedback.</p>
<p>As discussed in the previous section, building great products is largely about shortening the time between learning something from customers and acting on it. The faster a team can understand what customers want, which features they value, and what they are willing to pay for, the faster it can improve the product. Over time, this creates a powerful feedback loop that compounds into a better user experience and stronger monetization.</p>
<p>React Native can be a strong fit for this style of development. Services such as EAS and Codemagic allow teams to ship many changes instantly without app reviews, while tools like RevenueCat enable teams to experiment with pricing, offerings, and paywalls without requiring a new app release. Together, these tools can reduce the time between identifying an opportunity and delivering an improvement.</p>
<p>That said, the chart still suggests that execution matters more than stack choice. The difference between the highest- and lowest-performing apps within each stack is much larger than the difference between the stacks themselves. While React Native apps generate more revenue per install on average, the data does not suggest that React Native is a prerequisite for strong monetization.</p>
<h2>React Native pulls further ahead by day 60</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/6ef62c8d2b488ce7784fb942644fd07a7d2787a5-1496x1148.png" alt=""/></figure>
<p>Download to paid percentage, and revenue after 14 days tells about the early success React Native apps have with customers. Looking at the revenue per install after 60 days starts to tell a stronger story of React Native’s monetization performance. React Native not only seems to hold the gap, but actually widens it to other development stacks.</p>
<p>With median revenue per install at $0.51, compared to the $0.31 for native apps and $0.29 for Flutter apps, React Native apps earn almost 65% more revenue per install than the former, and 76% more than the latter. For reference, the numbers were x and y for 14-day revenue per install.</p>
<p>However, as with the day 14 data, the biggest differences are not between stacks but between apps. Within React Native alone, revenue per install ranges from roughly $0.16 at the lower end to $3.60 at the upper end.</p>
<h3>Third key learning: monetization is a product problem</h3>
<p>If monetization gains persist for two months, the explanation probably isn’t a better payment process. Realistically, it’s about the product being great. Monetizing well beyond the first month means that apps built with React Native are clearly valuable products that users want to pay for. To get to that, you need to be a product-oriented person or a team.</p>
<p>Something in React Native makes product-oriented teams choose it. Reasons are many, but they most likely come down to the benefits that it provides, most of which I’ve already mentioned. When I originally posted about how well React Native is monetizing, all the people who challenged the results were engineers. From an engineering perspective, React Native is easy to challenge; there’s a bunch of weird stuff if you compare it to something like Swift.</p>
<p>However, most customers are not engineers, and even if they are, they don’t approach the products they use from an engineering perspective. For them, it’s more important if the product solves the problem, not how.</p>
<p>What does this mean in practice for a developer who is building an app? First, focus on evaluating the benefits of the development stack from a product perspective: what gives you the best results with your current capabilities. Second, validate with a stack that gives you results the fastest. Nothing’s stopping you from going from React Native to Swift UI after you’ve built the first version of the app, and you feel that your technology of choice is the limit. As long as you don’t choose a technology based on what other engineers said, you’re probably good.</p>
<h2>Year-1 Retention by plan type shows only modest differences across stacks</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/759171663576e73136c0367b8510be86ad8567a4-1496x1148.png" alt=""/></figure>
<p>Here’s the real story of this article: retention at one year barely differs between Native, Flutter, React Native, or any other stack. That means React Native’s massive lead in conversion and revenue cannot be explained by better long-term retention. Users don’t stay subscribed longer in React Native apps. They stay about the same.</p>
<p>And that is the interesting part, because it tells us exactly where the advantage must be coming from:</p>
<ul>
<li>React Native apps convert more users earlier</li>
<li>React Native apps extract more revenue per install</li>
<li>But when it comes to keeping customers for a year, all stack perform similarly</li>
</ul>
<p>In other words, React Native wins by front-loading value, not by keeping users longer. If an app reliably solves a recurring problem, users stay subscribed. Data tells us that technology choices matter less in the long term, at least when it comes to keeping your customers.</p>
<h3>Fourth key learning: play long-term games</h3>
<p>The retention data tells an important story: stack choice has very little impact on whether customers stay subscribed over the long term. Annual retention rates are remarkably similar across Native, Flutter, and React Native apps.</p>
<p>Your app business should not be about stacks and hacks, at least if you’re in it for the long term. It’s possible to build a decent business by monetizing a loved product with a small but significant audience. As long as you keep those customers.</p>
<p>Frameworks can help teams move faster, experiment more often, and improve their products more quickly. But they cannot replace the fundamentals: understanding customer problems, building features people genuinely need, and delivering enough value to justify an ongoing subscription. Do that, and you keep your customers for longer. The longer you keep your customers, the more money they make. More money long term will eventually win over apps that convert better, or charge more (unless they also retain users better).</p>
<p>Retention data here is a reminder that customer loyalty is earned through product value, not technology choices. If customers continue finding value in your product months after they subscribe, they’ll keep paying regardless of whether the app was built with React Native, Flutter, or native technologies.</p>
<h2>React Native delivers the highest RLTV per paying user — by a wide margin</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/6bd9c029c1c0a925514d9f07702ce8bb52adebb8-1496x1208.png" alt=""/></figure>
<p>While the previous section brought some hope for people thinking that React Native is just an anomaly in early-stage monetization, our last chart about realized lifetime value (RLTV) is going to shatter that hope (at least a bit).</p>
<p>RLTV measures how much revenue a paying customer generates during their first year. In other words, once someone becomes a subscriber, how much are they actually worth to the business?</p>
<p>And the results are hard to ignore. The median React Native app generates $31.78 in first-year lifetime value per payer. Native and Flutter apps, meanwhile, are clustered around $21. That’s a difference of roughly 50%.</p>
<p>The gap isn’t limited to the median either. The top React Native apps generate more value from paying customers than the top Native and Flutter apps, reaching $95.32 in RLTV compared to $74.68 for Flutter and $61.81 for Native.</p>
<p>At this point, a pattern starts to emerge. React Native apps convert more users into customers. They generate more revenue after 14 days. They generate more revenue after 60 days. And now we can see that their paying customers are worth substantially more over the course of a year. As we saw in the last section, there’s no meaningful difference in retention between the stacks. Yet React Native apps still generate significantly more value per payer.</p>
<p>That suggests the explanation is not retention alone. React Native apps appear to be doing a better job of monetizing the customers they acquire, whether through pricing, packaging, product positioning, customer quality, or a combination of all four.</p>
<h3>Fifth key learning: valuable customers beat more customers</h3>
<p>One of the most encouraging things about this data is that it doesn’t suggest you need millions of users to build a successful app business. The apps that perform best are not necessarily the ones attracting the most customers. They’re often the ones creating the most value for the customers they already have.</p>
<p>Looking at the retention data, React Native apps don’t appear to keep subscribers significantly longer than Flutter or native apps. Yet those same subscribers end up being worth substantially more over the course of a year.</p>
<p>That should be good news for builders. It means success isn’t just about growth at all costs. You don’t need to win every download, dominate every category, or have the largest audience. Sometimes the biggest opportunities come from deeply understanding a smaller group of customers and building something they’re genuinely happy to pay for.</p>
<p>The best subscription businesses are often built this way. They solve a real problem, deliver enough value that customers stick around, and continuously improve over time. When you do that, each customer becomes more valuable. Not because you’re extracting more from them, but because you’re giving them more reasons to stay.</p>
<h2>Predictions for next year’s State of Subscriptions app report</h2>
<p>We now have two years of React Native leading the framework monetization section (report from 2025 showed similar results). The question is, of course, whether we still see similar results next year.</p>
<p>Last year we started a little bit of the agentic coding workflow, which is now the default for most developers. It will be interesting to see how that might change the results. Previously, React Native was argued to be the easy stack to land on if you were a beginner, or came from, for example, a web development background. Now that advantage is gone. You can easily build apps with any language, without having to understand it yourself.</p>
<p>What most likely happens is that we will get more apps than ever before, built in a variety of technologies. If developers focus on the principles of building great apps, the ones that this article has hammered on as well, we most likely see stabilization in the data. Perhaps a slightly hopeful prediction is that differences between frameworks will get smaller. Hopefully not at the cost of conversion, retention, and LTV. Realistically, we will see all of those go down, as the quality of new apps is generally low still.</p>
<p>But that could, of course, change as we are only halfway through the year.</p>
<p><a href="https://www.revenuecat.com/docs/getting-started/ad-monetization">We recently rolled out support for tracking ad revenue and impressions</a> alongside your subscription data. We will hopefully have this in next year’s report, as it might tell a completely different story when it comes to monetization. Who knows, maybe Flutter apps take the lead because they are better at utilizing subscriptions and ads together?</p>
<h2>Conclusion</h2>
<p>In this article, we’ve looked at all the different ways React Native apps are monetizing better than native and Flutter apps, and the reasons behind that. At each section, I’ve done my best to summarize how a developer should interpret this data and make use of it. Even if they’re not building with React Native.</p>
<p>The lesson for developers is simple: focus on building a tight feedback loop with your customers. Whether you’re using React Native, Flutter, or native technologies, the teams that learn fastest, iterate fastest, and continuously improve their product are the ones most likely to win. The strongest monetization outcomes don’t appear to come from a secret framework feature or growth hack. They come from building products that people truly value.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Why you should build another to-do list app (with a little help from Claude)]]></title>
      <link>https://www.revenuecat.com/blog/growth/austin-blake-stuff-launched-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/austin-blake-stuff-launched-podcast-2026</guid>
      <pubDate>Wed, 03 Jun 2026 12:47:46 GMT</pubDate>
      <dc:creator><![CDATA[Charlie Chapman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Austin Blake built a task manager in the most crowded category on the App Store — and it got featured in the Apple Newsroom.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/38da0459cbd13aeb5f2edcf63ba69164af58a08d-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>The conventional wisdom in the indie developer community is clear: do not build a to-do list app. It is the ultimate crowded market. It is the “hello world” of app development, a space dominated by Apple’s native Reminders, venture-backed giants, and a million abandoned weekend projects.</p>
<p>But when Austin Blake decided to go all-in on his indie career, he dropped five other projects to focus exclusively on Stuff, his aggressively delightful task management app.</p>
<p><a href="https://www.youtube.com/watch?v=cohtMYKXKYE">Watch on YouTube</a></p>
<p>“People will tell you that you can’t compete in a space,” Austin says. “And I’ve gotten tons of emails and feedback, some of which are pretty harsh. Like, ‘We don’t need another to-do list app, why are you wasting your time on this?’ But there’s always room for additional competitors. You just have to commit to it and spend time on it.”</p>
<p>In a conversation with Launched host Charlie Chapman, Austin shares how he built a native Mac app using AI agents, why he cut his free trial by 75%, and the brutal realities of launching on macOS.</p>
<h2>The “vibe coding” workflow: Letting AI agents peer-review each other</h2>
<p>Austin had never built a Mac app before. He started the traditional way—buying Paul Hudson’s Hacking with Swift: macOS Edition—but quickly pivoted to a highly modern, AI-driven workflow. He didn’t just ask Claude to write code; he built a multi-agent system to write and review his development plans.</p>
<p>“I make plans for everything, and then pass them between the agents and have them review each other’s plans,” Austin explains. “And then I review all of the code before it goes in.”</p>
<p>By letting one AI agent generate a technical plan, another critique it, and a third write the code, Austin bypassed the steep learning curve of AppKit and macOS-specific APIs. This collaborative loop saved him hundreds of hours of manual troubleshooting.</p>
<p>“Just the amount of things that I didn’t have to spend hours learning how to do on the Mac, and just letting that give me a first-pass prototype, saved me a ton of time and honestly let the app come out sooner than it would have.”</p>
<h2>The hidden design differences between iPad and Mac</h2>
<p>Porting an iOS or iPadOS app to macOS is rarely as simple as checking a box in Xcode. Even though modern iPads support larger screens, the user expectations on desktop are entirely different.</p>
<p>One major realization was the concept of selection states. On an iPhone, you tap a task and it immediately opens. On a Mac, users expect to navigate using arrow keys, highlight a task without opening it, and press Enter to expand the details.</p>
<p>“There’s this intermediate state that I had to add,” Austin says. “And then there are things like undo. Check a task complete and being able to undo it with Command-Z—that’s something you just expect to work. On iPhone, people don’t shake their phone violently to undo a task, but on Mac, it has to be a thing.”</p>
<p>Furthermore, while mobile design focuses on making full-screen experiences look good, desktop design requires optimizing for the incredibly small. Desktop users rarely full-screen their notes or reminders; they want to shrink them into tiny pockets of information pinned to the corner of their screen.</p>
<p>“You can make Stuff windows pretty dang small to the point where you could just have your monthly goals sitting up in a tiny box in the corner of the screen. There are a lot of affordances like that on Mac that make productivity apps in particular very fun to design for.”</p>
<h2>The 7-day trial experiment: Why less time means more conversions</h2>
<p>Like many subscription-based apps, Stuff initially launched with a one-month free trial. It felt generous, giving users plenty of time to experience the app. But Austin noticed a massive drop-off. With 30 days on the clock, users lacked the urgency to build a daily habit, and eventually forgot about the app entirely.</p>
<p>Without complex A/B testing tools, Austin trusted his gut and slashed the trial period down to seven days.</p>
<p>“I am a very bad tester, I’ll be honest. Everything I do is basically through gut,” Austin admits. “I saw a lot of drop-offs with trials, and I said, ‘One month is probably too long, I’ll drop it down to one week.’ And I did see a rise in conversions.”</p>
<p>Slashing the trial length kept the app top-of-mind. Users had to actively decide whether Stuff fit into their workflow while their initial excitement was still high, leading to a direct bump in paid subscriptions.</p>
<h2>The App Store review trap: Budget three weeks, not three days</h2>
<p>When Austin prepared to launch the Mac version of Stuff, he gave himself what he thought was a generous buffer: he submitted the app for review a full week before the scheduled launch date.</p>
<p>It wasn’t enough. The app was rejected multiple times—not for new Mac features, but for existing iOS features that had been approved in the App Store for years.</p>
<p>“I was rejected for a lot of different things over the Mac app, which are part of the reason it took so long,” Austin says. “But all of those things were already present on the iOS app. It was just me saying, ‘Hey, this is already approved. We’ve already talked about this.’ It was a lot of back and forth.”</p>
<p>Austin ended up launching three days later than planned, disrupting his coordinated marketing push. His advice for anyone launching a new desktop app? “Next time, I would give it honestly, probably three weeks. Just to give you plenty of time.”</p>
<p>In <a href="https://www.youtube.com/watch?v=cohtMYKXKYE">the full episode</a>, Austin also talks about his path from a BYU film major to working as a contractor at Apple, how Stuff was featured in the Apple Newsroom for its on-device foundation models, and his transition to joining the RevenueCat team as a Developer Advocate.</p>
<h2>Guest links:</h2>
<ul>
<li><a href="https://x.com/austinstuff">Austin Blake on X/Twitter</a></li>
<li><a href="https://apps.apple.com/us/app/stuff-task-management/id1663249463">Stuff on the App Store</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[20% of your churned users will come back – but are you ready?]]></title>
      <link>https://www.revenuecat.com/blog/growth/20-of-your-churned-users-will-come-back-but-are-you-ready</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/20-of-your-churned-users-will-come-back-but-are-you-ready</guid>
      <pubDate>Thu, 28 May 2026 08:16:41 GMT</pubDate>
      <dc:creator><![CDATA[Daphne Tideman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[The question isn’t if reactivation works; it’s whether you’re set up to capture it.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/f3576237cfbe97f1bbf760917b6ea14171b028c9-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Imagine you’re dating someone you really like and they suddenly end the relationship. Do you jump straight back onto the apps, chasing someone new? Or do you pause and ask the harder question: why? And maybe, if it feels fixable, try to convince them to give it another go.</p>
<p>I’m very glad my husband chose the second option when I got a classic case of cold feet months into our courtship. Otherwise, nearly ten years later, we wouldn’t still be together.</p>
<p>Most apps default to the first approach. They get caught up in the chase – dating as acquisition – endlessly pursuing the rush of new users rather than asking the ones who leave why they left. But reactivation is a huge, often-overlooked opportunity for apps that truly engage their audience.</p>
<p>Of course, not every relationship is salvageable. Sometimes they’ve found a better fit (whether that’s another app or another person), you’re out of their price range (a high-maintenance relationship), or they simply no longer need what you offer (they prefer the single life). But in many cases, users do come back. They might need your app again, or they might just be open to giving you a second chance.</p>
<p>Reactivation isn’t a universal strategy, but for the right apps, it’s one worth serious consideration.</p>
<h2>When reactivation happens, the most</h2>
<p>Reactivation is fascinating because it varies widely by price point, category, subscription length, and even geography. For one app, it can be a huge growth lever; for another, barely worth the effort.</p>
<p>Let’s break down the five key factors that shape reactivation:</p>
<ol>
<li>The use case</li>
<li>Subscription duration</li>
<li>Category</li>
<li>Price point</li>
<li>Geography</li>
</ol>
<p>I’ve tried to rank them by impact on reactivation.</p>
<h3>1. The use case</h3>
<p>Dan Layfield, founder of <a href="https://www.subscriptionindex.com/">Subscription Index</a>, makes a great point in the SOSA 2026 report:<em> “Users reactivate when the problem comes back, not when your win-back email lands.”</em></p>
<p>All the factors we cover are irrelevant if there is no reason for your users to come back. With a short-term use case, you won’t see high reactivation, no matter what the category and price point data suggest. As Dan puts it:</p>
<p><em>“The best products at win-back strategies serve a problem that recurs in the user’s life. Think about dating apps – you cancel when you’re in a relationship and come back when it ends. The same pattern plays out in fitness, entertainment, and any category where need is cyclical.”</em></p>
<p>Dan also highlights that there are <a href="https://subclub.com/episode/the-subscription-growth-formula-churn-math-retention-wins-and-smart-product-bets-dan-layfield-subscription-index">different durations apps get used for on a Sub Club episode</a>, and which one you fall into will impact your reactivation potential, and also how and when you can reactivate users:</p>
<ol>
<li><strong>Cyclical apps. </strong>These are the gold standard for reactivation. The user’s need naturally returns, often predictably. Dating, fitness, and weight management all fall into this bucket, whether it’s a breakup, a New Year reset, or a looming holiday. If you have a strong user context, you can almost anticipate when they’ll be back.</li>
<li><strong>Daily habit apps. </strong>Here, usage is ongoing, but fragile. Users don’t “finish” the product; they drift away. Reactivation is less about timing and more about emotion: why did they fall out of the habit, and what would pull them back in? Think meditation, language learning, or journaling. Winning these users back means tapping into motivation, guilt, identity, whatever drove the habit in the first place.</li>
<li><strong>Project-based apps. </strong>These are tied to specific, short-term goals. Once the task is done, the user leaves, but may return when a similar need arises. Photo editing, CV builders, productivity tools, and reactivation exists, but it’s irregular and often harder to predict.</li>
</ol>
<p>And then there’s a potential fourth category worth calling out:</p>
<h4>AI apps (the wildcard)</h4>
<p>AI products don’t fit neatly into any one bucket. Users often cycle through them by trying, churning, experimenting with competitors, and occasionally returning when something falls short elsewhere. That creates a unique dynamic: higher churn, but also surprisingly high reactivation potential. The trigger isn’t always a life event; it’s comparative value.</p>
<p>Before investing in reactivation, ask a more fundamental question: <em>Does your user have a reason to come back at all?</em></p>
<p>If you’re seeing strong activation and usage but consistently low reactivation, it may not be a failure of strategy. It might just be the nature of your product.</p>
<h3>2. Subscription duration</h3>
<p>Monthly subscriptions have always been tied to a more “let me give it a go” mindset, so it’s no surprise that, across geographies, price points, and most categories, they consistently deliver the highest reactivation rates.</p>
<p>This usually ranges from 18% to 24%, far exceeding the annual reactivation rate, which is more commonly around 4-6%. While the SOSA report only looks at overall reactivation rather than by subscription type, I’ve seen monthly subscriptions reactivate as annual subscriptions; users cancel their monthly plan after testing the app and then upgrade to an annual plan.</p>
<p>Annual subscription churn, it is sad to say, is much harder to reverse. They’ve spent a lot, they’ve decided to leave, and they aren’t likely to come back soon. At a 4-6% reactivation rate, you’re going to have to do a lot of sweet talking and work to get them back.</p>
<p>Interestingly, monthly reactivation exceeds the weekly reactivation rate, while weekly subscriptions are often used to try out an app; they reactivate at only 7-10%. My theory is that this is likely due to short-term use cases or users not having enough time to really get into the habit of using the app.</p>
<p>Now there are some categories where this varies, which brings us on to another major factor: category.</p>
<h3>3. Category</h3>
<p>From the State of Subscription App Report, we can see high variance in reactivation rates across categories.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/171b29fbd5856c28b0f8f4d814f89ffbb64e917c-2048x1162.png" alt=""/></figure>
<p>There are a few key honorary mentions among the many categories covered:</p>
<h4>A. Productivity</h4>
<p>36.1% reactivation rate for monthly subscriptions. This category is heavily skewed toward monthly subscriptions due to the nature of SaaS products, but I also believe AI is playing a role in driving this even higher, especially as it has increased significantly since last year (more on this later).</p>
<p>There is a lot of “serial dating” with AI tools, with users testing and trying a variety of new products. We know from the report that there is higher churn in AI apps, which could also be contributing to this greater reactivation opportunity.</p>
<h4>B. Gaming</h4>
<p>Here, users rarely come back; it is the lowest average reactivation category. Lose them, and you’re gone for good, despite their addictive nature. This may be a case of player fatigue. Churn seems to be more permanent in this category. I’m an example of this for sure; <a href="https://www.revenuecat.com/blog/growth/gamification-in-apps-complete-guide/">since beating my Township addiction driven by their strong gamification</a>, I haven’t been back (sorry, my little farm).</p>
<h4>C. Shopping</h4>
<p>Shopping and gaming are the only categories where weekly reactivation outperforms monthly subscriptions. For shopping, this reflects the more transactional and seasonal usage of shopping apps.</p>
<p>So, certain categories need to consider reactivation more seriously than others, as do certain price-point apps. A health &amp; fitness app on mid-range monthly pricing can expect around 12% reactivation from its churned pool. A productivity app at the same price point sits at 36% for monthly subscriptions. Same effort, three times the return, which is why category is a key variable to consider.</p>
<h3>4. Price Point</h3>
<p>The higher the price point, the more likely a monthly user will come back. The difference is significant: high-priced monthly apps reactivate at 28.9%, nearly double the 15.4% seen in low-priced apps.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/9efbbba986caca75f6b825790de7629b594829dc-2048x1159.png" alt=""/></figure>
<p>With high-priced apps, this could result from a higher initial commitment and a higher perceived value, making users more willing to return.</p>
<p>Interestingly, price point reactivation varies barely between weekly and yearly subscriptions. Only high-priced annuals are notably lower (4.4%) than low- and mid-priced apps (5.8% and 5.6%, respectively). Once a high-paying annual user is gone, they are hard to win back, so the focus should be on retention rather than reactivation.</p>
<p>So, high-priced apps with monthly subscriptions tend to have a bigger opportunity around reactivation. Now, what about geography?</p>
<h3>5. Geography</h3>
<p>I’ve seen some interesting differences in behavior from analyzing the geography graphs from SOSA (yes, I’m a huge nerd, I know). But reactivation – I am sad to admit – disappointed me.</p>
<p>Compared to other factors, geography plays only a minor role, especially compared to acquisition and retention, where we tend to see more variance.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/ebd3347b915276ff4c83d702e4eaf1f4e2cf3e95-2048x1164.png" alt=""/></figure>
<p>Overall, Asia-Pacific leads in monthly reactivation at 24%, while North America is the lowest at around 18%; US users tend to be more decisive when canceling, at least when it comes to monthly subscriptions. MEA and Western Europe show slightly stronger yearly retention, indicating potentially longer-term user commitment.</p>
<p>Given how small the variance is compared to category and price point, geography is the least actionable of the five factors for most apps.</p>
<p>Now, with so many factors that cause variation, how on earth do you know if reactivation matters for you? Slow down. First, you need to make sure you’ve got a strong foundation in place.</p>
<h2>Reactivation only comes after activation and habit building</h2>
<p>Even if your calculations show that reactivation is a huge opportunity, before you get excited and start working out how to win users back and give them another chance, there’s another factor you need to check first:</p>
<p>When are they zoning out of the relationship? Were they ever in it to begin with?</p>
<p>If we were to stick with the dating trope, it would be: did they lose feelings, or did they never really have them in the first place?</p>
<p>Okay, I’ll drop the analogy now, that’s enough from a married woman.</p>
<h3>Start by reviewing activation</h3>
<p><strong>If someone canceled after three days and never used your core features, winning them back isn’t a reactivation problem – it’s an activation problem wearing a reactivation costume (a real reactivation catfish).</strong></p>
<p><strong>Check your activation first.</strong> The signals that suggest you need to fix this before anything else:</p>
<ul>
<li>Trial-to-paid conversion is lower than you’d expect</li>
<li>New users aren’t reaching your core features in the first week</li>
<li>Day 7 retention is low</li>
</ul>
<p>The most useful thing you can do here is identify which early behavior best predicts long-term retention for your specific app. That’s your activation benchmark. If users aren’t hitting it, that’s where the work is.</p>
<h3>Then check habit formation</h3>
<p>It’s possible users activate fine, but never build the habit of coming back. Think of the journaling app you enthusiastically started in January. The stretching app that worked great until you went on holiday and somehow never picked it back up (the <a href="https://www.revenuecat.com/blog/growth/how-to-tackle-new-year-subscription-churn/">New Year’s subscription hangover</a> is a real thing).</p>
<p>The signals here are slightly different:</p>
<ul>
<li>Monthly subscribers churning at higher-than-expected rates in months 2-4</li>
<li>Annual subscribers canceling before they’ve had a chance to get value</li>
<li>Usage dropping off sharply around weeks 4-8</li>
</ul>
<p>You need to ensure you’re <a href="https://www.revenuecat.com/blog/growth/anton-derlyatka-sweatcoin-sub-club-podcast-2025/">building lasting habits</a>.</p>
<p>If activation looks healthy and habit formation is holding up, then yes, reactivation is the lever worth pulling. And if the data backs it up, it’s worth pulling hard.</p>
<h2>Reactivation is a growing opportunity</h2>
<p>Here’s what makes reactivation different from almost every other growth lever: the opportunity gets bigger without you doing anything.</p>
<p>Every month that passes, more of your users churn. That’s the bad news.</p>
<p>The good news is that each of those churned subscribers joins a pool that only ever grows. Unlike acquisition, where you have to constantly work to fill the top of the funnel, the reactivation pool accumulates automatically.</p>
<p>An app churning 200 subscribers a month has 2,400 former subscribers after a year, and 4,800 after two years. At the SOSA 2026, the average reactivation rate of 20% for monthly subscribers, that’s already 480 to 960 subscribers a year coming back without a single win-back campaign running.</p>
<p>To put that in revenue terms: if your monthly plan is $12, that’s between $23,000 and $46,000 in recovered annual revenue, just from organic return behavior, assuming a modest four-month average hold period post-reactivation.</p>
<p>Now imagine actively working on it.</p>
<h4>It is even growing year on year</h4>
<p>Reactivation rates aren’t static, and the shift from 2025 to 2026 is significant enough to be worth paying attention to.</p>
<p>Overall, monthly reactivation has jumped from 13.7% in SOSA 2025 to 20.1% in SOSA 2026. That’s nearly a 50% increase in a single year. Most of this movement is largely explained by one category.</p>
<p>Productivity went from 17.1% to 36.1%, more than doubling in twelve months. The timing aligns directly with the mainstream adoption of AI tools. In 2024, users were still discovering AI apps. By 2025, they’d had enough time to try several, be disappointed by at least one, and circle back to tools they previously wrote off. The churn-and-return cycle that defines AI app usage has become a defining feature of the Productivity category, and it’s pulling the entire industry average up with it.</p>
<p>But even by geography, we see that every region saw monthly reactivation rise substantially, though North America rose the least, moving from 14.1% to 18.0%.</p>
<p>Whilst productivity skews the overall averages, almost all categories saw a slight increase:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/12acc68eaf7bae53b81608e6d3bb3c99a9263666-1024x550.png" alt=""/></figure>
<p>I expect this trend will continue, with competition leading to more “let me test a few apps” behavior in other categories too.</p>
<h4>Lean into the compounding effect as you grow</h4>
<p>This has a compounding effect that most teams don’t account for. As your app grows, the churned pool grows with it. Reactivation as a share of total growth becomes more significant over time, not less. It’s why the most mature consumer apps – think Duolingo, dating apps, streaming services – treat reactivation as a core pillar of growth strategy, not an afterthought. They’ve simply been around long enough for the maths to become impossible to ignore.</p>
<p>So while reactivation might not be worth prioritizing when you’re just starting out, and your churned pool is small, it’s worth putting a flag in the calendar to revisit. The question to ask yourself every six months is: how big is my churned pool now, and what percentage of my growth could come from there rather than from new acquisition?</p>
<p>The answer tends to get more interesting over time.</p>
<h2>Is reactivation worth it for you?</h2>
<p>Here’s a rough decision guide before you use the calculator:</p>
<p><strong>Reactivation is likely worth prioritizing if:</strong></p>
<ul>
<li>Your use case is cyclical (dating, fitness, weight management, seasonal) or a daily habit (meditation, language learning, journaling)</li>
<li>Monthly subscriptions make up a meaningful share of your base</li>
<li>You’re in Productivity, Photo &amp; Video, Media &amp; Entertainment, or Social &amp; Lifestyle</li>
<li>Your activation and early retention metrics are healthy</li>
</ul>
<p><strong>Deprioritize reactivation if:</strong></p>
<ul>
<li>You’re in Gaming, the data suggests that once they’re gone, they’re gone</li>
<li>Your use case is genuinely one-and-done with no natural trigger for return</li>
<li>You haven’t yet solved activation or habit formation – fix the leaky bucket first</li>
</ul>
<p>Now, if you think reactivation is an opportunity for you, it’s time to bring out the big guns. By which I mean my<strong><a href="https://www.revenuecat.com/reactivation-calculator"> reactivation impact calculator</a></strong><strong>.</strong></p>
<h2>The reactivation impact calculator</h2>
<p>I’ll be honest about how this started. I was going to include a simple calculation in this article – a back-of-the-envelope number you could plug your churn rate into and get a rough revenue figure out of. That was the plan.</p>
<p>Then I started doing it properly. I pulled the SOSA data, started adjusting for category, then for price tier, then for subscription mix, and about an hour in I had a spreadsheet with seventeen tabs and a mild obsession I couldn’t quite explain to my husband.</p>
<p>The rough number I’d originally planned wasn’t just imprecise; it was misleading. A productivity app at a high price point and a gaming app at a low price point have almost nothing in common from a reactivation standpoint. Giving them the same benchmark would be worse than giving them no benchmark at all.</p>
<p>So I went back to the drawing board and built a proper one.</p>
<p>I’m slightly ashamed to admit how excited I got about the second version. My goal was simple: make something that could take your actual RevenueCat data (which is easy to access and doesn’t take long to pull) and tell you whether reactivation is worth your time, and if so, roughly how much it’s worth. Not a generic industry average, but your number, for your app, accounting for the factors that actually move the needle.</p>
<p>It took longer than expected, partly because the maths kept getting more interesting the deeper I went, and partly because I kept finding new edge cases (quarterly plans, anyone?). I stripped it back in the end to keep it simple.</p>
<p>A huge thanks to <a href="https://www.linkedin.com/in/ethan-garr/">Ethan Garr</a>, fellow growth advisor, who answered my increasingly specific questions with patience I did not deserve and gave suggestions that meaningfully improved the model.</p>
<p>The result gives you three scenarios – conservative, realistic, and optimistic – based on your category benchmark from SOSA 2026, adjusted for your price tier and subscription mix. It uses your actual subscriber data from RevenueCat, where available, and falls back to the SOSA benchmark otherwise.</p>
<p><a href="https://www.revenuecat.com/reactivation-calculator">The calculator</a> takes into account everything we’ve covered – your category benchmark from SOSA 2026, your price tier, and your actual subscriber data from RevenueCat. It shows you three scenarios (conservative, realistic, optimistic) based on your specific profile, not a generic industry average.</p>
<p>Here’s how to fill it in:</p>
<ol>
<li><strong>Select your app category</strong>. This sets your SOSA 2026 reactivation benchmark.</li>
<li><strong>Enter your pricing per plan type</strong>: monthly, annual, weekly, or quarterly. This adjusts the benchmark for your price tier (high-priced monthly apps reactivate at nearly double the rate of low-priced ones).</li>
<li><strong>Find your active subscriber counts</strong> in RevenueCat → Charts → <a href="https://www.revenuecat.com/docs/dashboard-and-metrics/charts/active-subscriptions-chart">Active Subscriptions</a>. Segment by Product Duration. Take the last 3 months and average them.</li>
<li><strong>Find your monthly churn</strong> in RevenueCat → Charts → <a href="https://www.revenuecat.com/docs/dashboard-and-metrics/charts/active-subscriptions-movement-chart">Active Subscriptions Movement</a>. Segment by Product Duration. Same 3-month average.</li>
<li><strong>Add your reactivation count if you have it</strong>. The same chart, but filter by Product Duration rather than segment. This makes the model significantly more accurate.</li>
</ol>
<p>It should take you about five to ten minutes to fill in. Please don’t take the output as gospel; it still doesn’t capture everything (regional pricing differences, for instance, which I consciously decided not to include before I ended up with twenty-three spreadsheet tabs). But it should give you a defensible enough number to answer the question that actually matters: is this worth focusing on?</p>
<p>You’ll get a worst / mid / best case scenario, and you can even play through different scenarios. If you want to share this internally, I’ve written copy-and-paste text explaining how the calculator works.</p>
<p>Again, a special thanks to Ethan Garr, a fellow growth advisor, who helped me build this, taking all the <a href="https://www.revenuecat.com/blog/engineering/vibe-coding-reality-vs-hype/">lessons he learned from vibe coding a subscription app</a>.</p>
<h2>The churned list is getting bigger every month</h2>
<p>The problem isn’t that reactivation doesn’t work. The data shows it does – significantly, for the right apps. The question is whether it works for yours, and whether the opportunity is big enough to act on right now.</p>
<p>What we know: a meaningful share of your churned subscribers will come back whether you do anything or not, but there is still a lot you can do to improve that – from <a href="https://www.revenuecat.com/blog/growth/win-back-campaign-examples-ideas/">winback campaigns</a> to <a href="https://www.revenuecat.com/blog/growth/retargeting-ads-an-overlooked-tactic-for-winback-reactivation/">retargeting ads</a> to <a href="http://revenuecat.com/blog/growth/app-cancellation-flow-best-practices">improving your cancellation flow</a>. The ones who return are the ones for whom your app genuinely solved something. Your job isn’t to convince people who were never going to stay; it’s to be ready for the ones who are.</p>
<p>The churned list you’ve been ignoring is getting bigger every month. The question isn’t whether reactivation works. The question is whether you’re there when your user is ready to come back.</p>
<p><em>In Part 2, we’ll get into what a reactivation program actually looks like: timing, segmentation, channels, Apple win-back offers, web billing discount flows, and retargeting – the how behind the why.</em></p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[She turned down $500k a year in brand deals to build trust. Result? A trial CVR of 68%.]]></title>
      <link>https://www.revenuecat.com/blog/growth/nancy-anderson-natal-sub-club-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/nancy-anderson-natal-sub-club-podcast-2026</guid>
      <pubDate>Wed, 27 May 2026 12:57:26 GMT</pubDate>
      <dc:creator><![CDATA[David Barnard]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Nancy Anderson built a pre and postnatal fitness app to over a million users without lead magnets, a free trial, or a growth metric that could explain why it worked.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/b6c10bbee1616f07d7693bfc3d064372ba30f544-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<h2>The experiment that came from a podcast episode</h2>
<p>Nancy Anderson’s biggest win of the past year didn’t come from her own product team. It came from listening to <a href="https://www.youtube.com/watch?v=-GHpDJm8MgY">a previous Sub Club episode featuring Zumba</a>.</p>
<p>The tip: remove the free trial from the monthly subscription option. Anderson was skeptical. “I was shocked that it worked personally. I was like, is that going to work?” She ran the experiment anyway. Monthly subscriptions went up 2,000%. Quarterly subscriptions rose 46%. Annual subscriptions rose 21%.</p>
<p><a href="https://www.youtube.com/watch?v=gqN6Z5WAeiI">Watch on YouTube</a></p>
<p>The result wasn’t just a win for the revenue line. It validated the core thesis she’d been building her business on for eight years: if you build enough trust before the paywall, users don’t need a test drive. “They come in, they don’t even want or need a trial. They’re ready to buy.”</p>
<h2>The 68% trial conversion rate (and why it has nothing to do with the paywall)</h2>
<p>Natal’s conversion metrics are hard to explain through conventional CRO logic. When users hit the web checkout, 93% of them download the app. Their trial conversion rate is 68%, against a health and fitness industry average of around 38%. They achieve this at $25 a month — a premium price for the category.</p>
<p>Anderson’s explanation is simple and inconvenient for anyone who prefers a spreadsheet: it’s trust, built long before the paywall.</p>
<p>“It’s just a signal that trust has been built before the paywall,” she says. “So when they get to the paywall, it’s not feeling risky to them, it’s not feeling scary… They already trust us.”</p>
<p>That trust is operationalized in a way that most apps would find unscalable. For eight years, every DM, comment, and email across all platforms has been answered within 24 hours — not by AI, not by a customer service script, but by real coaches, physical therapists, and pelvic floor specialists. It costs more per hour than a standard support rep. Anderson considers it the most important line item in the budget.</p>
<p>“Our organic content outperforms our competitors by 90%,” she notes. The paywall conversion numbers are the downstream signal of that investment.</p>
<h2>The growth lever no dashboard can measure</h2>
<p>The central tension in Natal’s growth strategy is that its most powerful levers produce no direct data point. You can’t run an A/B test on empathy. You can’t isolate the ROI of a specific DM conversation in a Shopify report.</p>
<p>“Growth would be easier if I could [A/B test trust],” Anderson admits. “You go to these meetings with men and women who are just so data driven… well, why would we do that? We can’t measure it. And it’s like, you can’t measure everything in your dashboard.”</p>
<p>This is also how Natal’s hero program — Ab Rehab, which has driven over a million users to the app — was built. Not from a market analysis, but from noticing a recurring pattern in Facebook group comments and Instagram DMs from women asking for help with postpartum core recovery. “I never would have built it if I wasn’t close to the customer,” Anderson says. “That never would have showed up in my Shopify reports.”</p>
<p>The downstream signal of all this unmeasurable trust? 20% ARR growth in Q1 — while raising prices.</p>
<h2>HSA payments: unlocking a whole new audience</h2>
<p>Natal is also among the first fitness apps to accept Health Savings Account (HSA) and Flexible Spending Account (FSA) payments at checkout, through RevenueCat’s integration with Flex.</p>
<p>The motivation was straightforward: Anderson had been hearing for years from women who wanted the app but couldn’t afford it. Because Natal’s programs include corrective exercise and physical therapy protocols for postpartum recovery, they qualify as eligible HSA expenses. Accepting those pre-tax dollars effectively cuts the out-of-pocket cost by 30–40% for eligible users.</p>
<p>What surprised Anderson was how quickly users found it without any promotion. “People are just finding it at checkout and using it,” she says. The end-of-year HSA spending deadline — when users must spend their remaining balance or lose it — creates a natural, high-intent promotional window that most health and fitness apps aren’t yet capitalizing on. “Don’t lose your HSA. You can use it to do our programs, to use our app.” For apps in the health, fitness, or mental health space, this is one of the most concrete near-term growth opportunities available.</p>
<p><a href="https://www.youtube.com/watch?v=gqN6Z5WAeiI">In the full episode</a>, Nancy and David also discuss why she turned down $500k a year in brand deals, the mistake of building four separate apps instead of one, and why free workouts attract the wrong audience.</p>
<h2>Guest links:</h2>
<ul>
<li><a href="https://www.instagram.com/nancyandersonfit/">Nancy Anderson on Instagram</a></li>
<li><a href="https://www.natalapp.com/">Natal App</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[How to build a UA system when you're a one-person team]]></title>
      <link>https://www.revenuecat.com/blog/growth/how-to-build-a-ua-system-when-youre-a-one-person-team</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/how-to-build-a-ua-system-when-youre-a-one-person-team</guid>
      <pubDate>Tue, 26 May 2026 08:15:00 GMT</pubDate>
      <dc:creator><![CDATA[David Vargas]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[What seven years and 125 apps taught me about running paid campaigns without a team.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/b6b867d75bc5470c85fc97560bb87c6be9ad8e5c-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>I've said this many times, but here we go again: AI has flooded the stores with vibe-coded apps as the barrier to building an app has been drastically lowered.</p>
<p>There are more and more solopreneurs trying to make a living from their apps, but the more I talk to them about User Acquisition, the more I see the same pattern: they try to replicate what they see in success stories from 5 or 10-person teams. The result? Everything gets done halfway.</p>
<p>And the solution isn't working more hours. It's designing a smaller but complete system that covers all the basic aspects you need to scale UA until you get to the point where you can afford to bring someone else onto the team.</p>
<h2>Warning before starting: AI is needed</h2>
<p>Although this point can be unnecessary, I have to say it before I start elaborating the whole article: You will need to rely on AI for multiple parts of the system because the day only has 24 hours and you also have to get some time to eat, sleep and be a human being.</p>
<p>I assume that for most solopreneurs developing apps, AI is already like a third arm for them and they probably use it better than I do but if not, stop reading now and start learning how to use Claude or any other LLM and then come back here to build your UA system.</p>
<p>Besides that, I also want to clarify that I don't have all the answers. I am just explaining what I have done for small projects and how you can apply what I have learned after running UA strategies for more than 125 apps in the last 7 years. This means that you may agree or disagree with some points or maybe you even do some processes faster or more efficiently already. That is completely fine. I am just elaborating on this system to help all those people who don’t know where to start.</p>
<h2>The four pillars you can’t skip in UA even when you’re flying solo</h2>
<p>Running UA efficiently is not that complicated when you’re trying to go 0 to 1. Things get more messy as you grow but for this system we’ll focus on having something that works well for apps going 0 to 1.</p>
<p>In this regard, there are four pillars that need to be addressed or controlled if you really want to achieve decent success with your paid campaigns:</p>
<ul>
<li>Channels</li>
<li>Creative Production</li>
<li>Tracking</li>
<li>Schedule</li>
</ul>
<p>If you manage these four variables more or less efficiently with the tricks and strategies that I’ll explain in the corresponding sections, you won’t need to spend money hiring someone until reaching a decent level of growth. Moreover, you will also learn how UA works along the way, so when the time to hire comes, you will at least understand the foundations and principles of UA.</p>
<p>With that being said, it’s time to lay out the plan.</p>
<h3>Pillar 1: Channels - Where should you promote your app?</h3>
<p>The biggest mistake solopreneurs make is launching on three channels (Meta, TikTok, ASA) at once. As Lucas Moscon explains in his piece on <a href="https://www.revenuecat.com/blog/growth/ad-channel-diversification/">ad channel diversification</a>, launching more channels does not necessarily mean lower risk. Quoting the article as it explains it perfectly in its first paragraph:</p>
<p><em>“Ad channel diversification can reduce performance when budgets, audiences, or team capacity are too limited to support multiple learning phases, creative pipelines, or attribution workflows.”</em></p>
<p>I know that when you’re starting you feel the FOMO for not being running campaigns in all the possible channels and figuring out which one brings the best performance for you but that feeling is actually counter-productive. Based on my experience, if your product has PMF and the main metrics are within the benchmarks of the category (discover your benchmarks with RevenueCat's <a href="https://www.revenuecat.com/healthscore/">calculator</a>), the difference that you will observe across channels is simply not determining your success or failure when running UA.</p>
<p>You can get 15-20% better CPAs just by changing the channel but that potential improvement comes with a high cost for a one-person team: new creative formats, new attribution logic, new account management. In the best-case scenario, you get an improvement that is not going to determine your viability to grow but in the worst-case scenario, you will burn out in less than a month for not seeing results in any of the channels after investing an unnecessary amount of time.</p>
<p>So the first learning is simple: Pick one channel and make it work at a decent level of spend. Once you start to struggle with scalability and your creative production system can double the production with no issues, then you can start considering other channels.</p>
<p>Since I’m assuming that you’re solo, I will briefly talk about the main channels as these must be the ones to consider when you want to start UA for the first time:</p>
<ul>
<li>Meta Ads → It fits well for most app categories and gives you 5 different networks at the same time to deliver your ads (Facebook, Instagram, WhatsApp, Threads and Audience Network). This gives more than enough to find your ICP and prepare ads specifically for them. This is my pick in +90% of the subscription apps I work with.</li>
<li><a href="https://revenuecat.com/blog/growth/tik-tok-ads-guide-mobile-apps/">TikTok</a> → If your ICP is below 40 years old, TikTok is definitely a channel to consider although it demands a higher velocity with the creative production as the algorithm tends to burn creatives out much faster than any other channel. Sometimes you can get better CPAs here but you will need to rotate twice as many creatives easily compared to Meta.</li>
<li>Google → They launched OCM+IDM this year to offer real-time signals and more accurate reporting for app campaigns but it’s still too inconsistent. If you want to go with Google, you must go with web-to-app campaigns. DemandGen and Search campaigns are in this case the first ones to test.</li>
<li>Apple Ads → If your category has a clear search intent you can try this channel but right now, Apple Ads is very competitive and if you target the US, you won’t likely win many bid auctions at the price that you need to be profitable.</li>
</ul>
<p>Except for Apple Ads, all the other channels allow you to optimize your campaigns either for installs, events or value. My recommendation is to focus directly on events if you really want to see how the algorithm looks for potential subscribers for you.</p>
<p>The quality that install campaigns bring is just too low to be sustainable and I only run these campaigns in very specific cases for creative testing but never as a growth pillar in my UA campaigns.</p>
<p>Regarding VO campaigns, these are a next step further as they rely on the value that you send to the ad network and if your subscription value happens on day 3 or day 7 because you offer a free trial, that is too late to get a proper learning so you’d need to work with proxy values and that is definitely a topic that you don’t need to know in the beginning (watch the <a href="https://www.youtube.com/watch?v=nm2RngDr2AY">Signal Engineering webinar with Thomas Petit</a> if you want to learn more about this topic).</p>
<h3>Pillar 2: Creative Production - How many creatives do you need? And how should you structure them?</h3>
<p>It doesn't matter how good you are with AI: You will never outproduce a 5-person team. So don't try it.</p>
<p>I started with that sentence because after creating an X account this year, I see an endless amount of posts every day where solopreneurs brag about the complex systems they have for their paid campaigns. While they make less than $10k MRR.</p>
<p>Let me tell you something: Having successful paid campaigns is not that hard if you apply common sense. You just need to steer the algorithms to deliver your creatives towards the audiences that you consider valuable for your business.</p>
<p>I've already written about <a href="https://www.revenuecat.com/blog/growth/subscription-app-creative-testing/">how I approach creative testing for each stage</a>, but this time I will elaborate a bit on the logic behind the flowcharts rather than the rotation process, which the article already covers in detail.</p>
<p>When you're starting from scratch, you have an idea of what audiences can work best but you don't have the data to confirm it. Therefore you must create different creatives that attack all the pain points your potential customers may face and show how your app solves those problems.</p>
<p>Applying the first structure from that article ($0 to $500), your first task is to find which angles resonate best for your target event. Start your campaign with a single ad group and put different creatives that attack different angles and audiences. Check the metrics and then start to pick winners and losers every week while you keep refreshing the campaign according to performance.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/bfea724b621b9dab2826ea3a2b5abf47c9516c19-933x410.png" alt=""/></figure>
<p>This first stage won't give you deep granularity on the performance per audience or angle of creative but it will help you to confirm the main hypotheses you can test in this first step:</p>
<ul>
<li>What is the average CPI and CPA you can expect from a CPA campaign and whether that aligns with the LTV that you have projected</li>
<li>What conversion rates you get from paid campaigns vs organic at each step of the funnel and if these metrics are within benchmarks for your category</li>
<li>What angles get the best performance</li>
<li>How the algorithm distributes the spend towards the different angles</li>
<li>What age groups and placements absorb more spend for each angle</li>
</ul>
<p>I have also written about <a href="https://revenuecat.com/blog/growth/creative-fatigue-mobile-apps-roas/">creative fatigue</a> and how to detect when it's time to rotate creatives in your campaigns, so if you wonder what metrics you should look at, go and check that article.</p>
<p>Once you have found the answer to all these questions and while you have scaled to over $500/day, it is time to move to the second structure where you will have much more granularity and space to test more and also push the audiences that work better.</p>
<p>In that second scenario, you will likely be at a point where you need someone else helping you to produce creatives as you will have multiple ad groups that need to be fed with different creatives, every single one at least once a week (but likely twice according to my experience).</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/d174d4eae8fcdfd2b5491c38685f8f9e813912ac-1617x871.png" alt=""/></figure>
<h3>Pillar 3: Tracking - What tools do you need to track the performance and how you should track it in the beginning</h3>
<p>Let's be clear: You don't need a $30k/year MMP setup to start any campaign and see if you can really scale UA. <strong>In fact, you don't need to spend anything on tracking tools for your campaigns</strong> as all the main networks (Meta, TikTok and Google) offer free solutions to track the campaigns.</p>
<ul>
<li>Facebook and TikTok have their own SDKs</li>
<li>Google has Firebase (which demands another SDK)</li>
</ul>
<p>So the only tools that you need to measure your campaign performance are:</p>
<ul>
<li>RevenueCat for LTV, Cohorts and subscription data (non-negotiable)</li>
<li>The SDK of the ad network that you start with</li>
<li>App Store Connect</li>
<li>A spreadsheet</li>
</ul>
<p>In today's market, you will likely get close to zero pure organic traffic from the store, but if you are running organic videos on Facebook, Instagram and TikTok, you may get some installs and in-app purchases from there. So when you start your paid campaigns, you won't really know what can be attributed to the campaign by just looking at App Store Connect.</p>
<p>I think this is the most likely scenario for all solopreneurs trying to scale their apps and that is why I will show what I recommend in this stage.</p>
<p>Once you set up the ad network SDK and start running your first paid campaigns, you will start seeing conversions attributed to your campaigns. The first thing is to realize how many real conversions are happening, as the ad networks normally use probabilistic attribution for iOS campaigns and that can result in campaigns under- or over-reporting (in Android, you can fully rely on the number that you see in the ad network).</p>
<p>The simplest way to measure that is by checking the difference in the baseline before and after running the campaign across all the data points that you may have in place and attribute the uplift in the baseline to the paid campaigns.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/0377333f7abed360d93b016e2f34db81827ea163-690x423.png" alt=""/></figure>
<p>Obviously, you need a stable period to establish your baseline across your data points (RevenueCat, ASC and your HDYHAU survey in case you have it in the onboarding). Then, you will clearly see how that baseline jumps the moment that you start the paid campaigns, so attributing that difference will tell you how much you are basically paying per trial or direct subscription.</p>
<p>Moreover, by creating different baselines across different data layers (RC, ASC, HDYHAU), <strong>you will start to see what the usual discrepancy is between platforms</strong>, so you will start to learn what kind of margin you have when you make decisions when looking at your campaign's numbers.</p>
<p>After getting that CPA, you should go to your RC chart section and pull the LTV data for paying users so you can see if that CPA is low enough to cover:</p>
<ul>
<li>Your LTV in the timeframe that you need (in case it's a direct subscription)</li>
<li>Your trial start and trial conversion rate (in case you optimize for trials).</li>
</ul>
<p>In parallel to this rudimentary (but reliable) attribution for your only active channel, you must create a <strong>blended report that gives you a high-level overview of your business</strong>: How much you spend and generate per day across all channels, how many installs you get, how many trials and how many subscriptions based on your average trial conversion rate. You must also create LTVs for different periods of time and then project the revenue on that period of time so you can see where things stand today (ROAS) and what the expected profitability is for the trials that you acquired today across all your channels.</p>
<p>I have a real example of one of these reports that I helped create last month:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/1e1bed2a1facfb5e69e11592f3998be4631df8ce-584x402.png" alt=""/></figure>
<p>As you can see, it is pretty straightforward and gives you a really clear vision of how your business is doing today and how much margin you have in the future based on your investment and conversion rates. This is also great because it quickly tells you if you are suffering any drop-off on the product side or if you are losing efficiency at any step of the funnel.</p>
<h3>Pillar 4: Schedule - How often should you execute?</h3>
<p>The main advice here is pretty clear: Consistency will always beat intensity, so drill this into your head: <strong>Checking campaigns 5 times a day and making reactive changes will hurt your paid performance more than help it.</strong></p>
<p>If you are running a campaign with less than $500/day, you will need to execute changes twice a week at most. The reason is that your budget is simply not high enough to feed your creatives enough to make more changes, and since in the first stage you must simply have one ad group with different angles, the only iterations you must do is to pause the creatives that don't perform and upload others to test.</p>
<p>In order to give you a more detailed plan rather than rough recommendations, I took the liberty of creating a plan specifically for UA so you can use the rest of the time for other tasks that are also super important for your business (product improvements, A/B testing, organic distribution, finance, etc):</p>
<ul>
<li>Monday (1-2 hours): Refresh your campaign report and blended report and analyze how the week closed compared to the previous one (creative distribution, placements, age, engagement metrics, etc). Decide if any creative needs to be paused and analyze the distribution and metrics of the winning ones so you can think of more ideas related to that winning angle. Sketch out a potential A/B test you would like to test in the paywall and plan a 1-week test for it.</li>
<li>Tuesday (3-4 hours): Plan the new batch of 3-5 assets you want to produce this week after you gather all the insights from the reports that you refreshed yesterday, and try to produce them within the same day if possible.</li>
<li>Wednesday (1 hour): Check the campaign and see how the creatives you uploaded on Monday are performing. Pause older creatives that didn't get spend or didn't perform well and upload 1 or 2 from the batch that you created.</li>
<li>Thursday (20 minutes): Refresh the reports and make sure everything goes right. Pause bad-performing assets if and when they have enough impressions (10k+).</li>
<li>Friday (1 hour): Refresh the campaign again with the remaining assets that you have from the batch from Tuesday if it's necessary. If performance is good, save them and simply decide if you want to scale or decrease the budget by looking at the performance of the week.</li>
</ul>
<p>Besides these tasks, you must spend 15 minutes extra every day. During these 15 minutes, only look at two things:</p>
<ul>
<li>Is the pacing correct?</li>
<li>Are there any major anomalies that need to be investigated right away?</li>
</ul>
<p>You can also automate some of the decisions with automated rules as both Meta and TikTok let you set rules like &quot;pause this ad group if CPA exceeds $X over the last 3 days.&quot; Set them up once and let the platform handle the routine decisions. This is the one place where you let the algorithm work for you instead of against you.</p>
<h2>When and how to hire your first external help</h2>
<p>If you start scaling, there will be a point of course when you will be overwhelmed by any of these pillars and that is totally fine. You will simply start hitting the ceiling of what one person can manage, and that's not because the system is broken, but because the opportunity will outgrow the system. That's when you start thinking about your first hire or freelancer to delegate: a creative producer, a data analyst or a part-time UA manager. These are the main roles that would free up the time you'd otherwise spend on UA.</p>
<p>The best process to see which role suits your company the best is to analyze your own bottlenecks:</p>
<ul>
<li>If creatives are slowing you down → freelance creative on a per-project basis</li>
<li>If analytics is overwhelming → data analyst by the hour</li>
<li>If campaigns are eating all your time → consider a part-time media buyer</li>
</ul>
<p>The rule here is to outsource what is repetitive and executable, not what is strategic.</p>
<p>I hope this guide helps you to thrive in this competitive market, whether you're starting UA from scratch or your current system isn't working.</p>
<p>I will happily help anyone with further questions on my <a href="https://www.linkedin.com/in/davidvargasmontiel/">LinkedIn</a> or <a href="https://x.com/davidvargasm_">X</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Meet the RevenueCat AI Toolkit]]></title>
      <link>https://www.revenuecat.com/blog/company/ai-toolkit</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/company/ai-toolkit</guid>
      <pubDate>Fri, 22 May 2026 17:48:34 GMT</pubDate>
      <dc:creator><![CDATA[Niklas Winkels]]></dc:creator>
      <category><![CDATA[Company]]></category>
      <description><![CDATA[The AI Toolkit brings RevenueCat into the AI coding tools where app work already happens, so your agent can help set up subscriptions, integrate the Purchases SDK, build paywalls, check metrics, and debug purchases.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/a1764550d8cbed85e043504abc1a43756d8944f4-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>For a growing number of app teams, setup now starts with an agent. The agent sees the codebase, knows the platform, can edit files, can run commands, and can help connect RevenueCat with the app.</p>
<p>Meet the RevenueCat AI Toolkit. It brings RevenueCat into your AI coding assistant, starting with a Claude Code plugin that can help configure RevenueCat, integrate the Purchases SDK, inspect project health, pull RevenueCat Charts data, and troubleshoot in-app purchase issues from the tools where you already write code.</p>
<p>The AI Toolkit gives your agent access to both the RevenueCat context and the skills it needs to understand it.</p>
<h2>Meet the RevenueCat AI Toolkit</h2>
<p>The RevenueCat AI Toolkit is distributed as a plugin you install from your AI coding assistant’s marketplace. It is available for Claude Code, and it also works with OpenAI Codex, Gemini CLI, and Visual Studio Code. Cursor support is coming soon; for now, Cursor users can connect the RevenueCat MCP server directly.</p>
<p>The plugin gives your agent two things. First, it includes RevenueCat MCP server configuration, which lets your agent access RevenueCat after you authenticate with OAuth. Second, it includes RevenueCat-specific skills, which guide the agent through setup, SDK integration, paywalls, purchases, testing, analytics, and troubleshooting.</p>
<p>Think of it as access plus knowledge. MCP gives your agent a way into RevenueCat. Skills tell it which steps to take next.</p>
<table>
<thead><tr>
<th><p>Feature</p></th>
<th><p>What it gives your agent</p></th>
<th><p>Why it matters</p></th>
</tr></thead>
<tbody>
<tr>
<td><p>Plugin</p></td>
<td><p>RevenueCat inside Claude Code, OpenAI Codex, Gemini CLI, and Visual Studio Code agent plugins beta.</p></td>
<td><p>You can work on monetization where your app code already lives.</p></td>
</tr>
<tr>
<td><p>MCP server configuration</p></td>
<td><p>A connection to RevenueCat through OAuth.</p></td>
<td><p>Your agent can read and update RevenueCat configuration with your account permissions.</p></td>
</tr>
<tr>
<td><p>Skills</p></td>
<td><p>RevenueCat-aware playbooks for common workflows.</p></td>
<td><p>Your agent follows the right order of operations instead of guessing from generic docs.</p></td>
</tr>
</tbody>
</table>
<h2>What you and your agent can do</h2>
<p><strong>Subscription infrastructure from the editor.</strong> Instead of coordinating every setup step by hand, you can ask your agent to create or select a project, add apps, create products, define entitlements, build offerings, attach packages, and return the public SDK keys your app needs.</p>
<p>This matters because order matters in RevenueCat. Products need to map to entitlements. Offerings need packages. Packages need products. Apps need the right public SDK keys. The AI Toolkit helps your agent follow that order.</p>
<p><strong>SDK setup with RevenueCat context.</strong> Your agent can detect whether your app is native iOS, native Android, React Native, Flutter, or Kotlin Multiplatform. Then it can install the right Purchases SDK, configure Purchases once at app launch, enable debug logs during setup, and verify the expected SDK output.</p>
<p>That saves more than tab switching. It reduces the classic mismatch between product configuration and app code: the wrong key, the wrong app identifier, or a configure call in the wrong place.</p>
<p><strong>Paywalls and purchases where the code lives.</strong> If you use RevenueCat Paywalls, your agent can add the right RevenueCatUI package and present the dashboard-configured paywall from your app. If you need custom UI, it can guide the purchase flow around <code>getOfferings()</code>, <code>purchase(package)</code>, restore purchases, and entitlement checks.</p>
<p><strong>Analytics and troubleshooting inline.</strong> Your agent can inspect project status, query RevenueCat Charts, and help diagnose common setup issues like empty offerings, missing products, inactive entitlements, or sandbox purchase failures.</p>
<h2>How to install it</h2>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/e119d780cf36aaaca117f77ad44de5a1f10a64bb-1920x1080.png" alt="revenuecat ai toolkit"/></figure>
<p>Start with Claude Code. From inside Claude Code, run:</p>
<pre><code class="language-bash">/plugin</code></pre>
<p>Choose <strong>Marketplace</strong>, select <strong>+ Add Marketplace</strong>, enter:</p>
<pre><code class="language-bash">RevenueCat/ai-toolkit</code></pre>
<p>Then install the <strong>RevenueCat</strong> plugin.</p>
<p>You can also install it from the command line:</p>
<pre><code class="language-bash">claude plugins marketplace add RevenueCat/ai-toolkit
claude plugins install RevenueCat</code></pre>
<p>For OpenAI Codex CLI, add the marketplace first:</p>
<pre><code class="language-bash">codex plugin marketplace add RevenueCat/ai-toolkit</code></pre>
<p>Start Codex, run <code>/plugins</code>, search for <strong>RevenueCat</strong>, and install the plugin.</p>
<p>For the OpenAI Codex desktop app, run the same marketplace command in your terminal:</p>
<pre><code class="language-bash">codex plugin marketplace add RevenueCat/ai-toolkit</code></pre>
<p>Then open <strong>Plugins</strong> in the Codex app, select <strong>RevenueCat</strong> from the plugin source dropdown, and click the plus button next to the plugin.</p>
<p>For Gemini CLI, install the extension directly from GitHub:</p>
<pre><code class="language-bash">gemini extensions install &lt;https://github.com/RevenueCat/ai-toolkit&gt;</code></pre>
<p>For Visual Studio Code, use the agent plugins marketplace beta. Add this repository as a plugin marketplace, then install the RevenueCat plugin.</p>
<p>Cursor support is coming soon. Until then, configure the RevenueCat MCP server directly in Cursor using the setup instructions in the RevenueCat docs.</p>
<p>For other AI coding environments, you can install the skills with:</p>
<pre><code class="language-bash">npx skills add RevenueCat/ai-toolkit</code></pre>
<p>That fallback installs the skills, not the MCP server. If you want live RevenueCat project access in an unsupported environment, configure the MCP server manually.</p>
<h2>What you can ask it</h2>
<p>Start with the job you would otherwise split across your editor, RevenueCat setup, store consoles, SDK docs, and logs.</p>
<pre><code class="language-bash">Set up RevenueCat for my fitness app. I’m building for iOS and Android. I want monthly and annual subscriptions.</code></pre>
<pre><code class="language-bash">Integrate RevenueCat in this React Native app and configure the SDK with the correct public API key.</code></pre>
<pre><code class="language-bash">What’s the status of my RevenueCat project?</code></pre>
<pre><code class="language-bash">What was my revenue last month, and how does churn look over the last 90 days?</code></pre>
<pre><code class="language-bash">My offerings are empty on iOS sandbox. Diagnose the RevenueCat setup and tell me what to fix.</code></pre>
<h2>Frequently Asked Questions</h2>
<p><strong>What can an agent do through the AI Toolkit?</strong></p>
<p>Your agent can use RevenueCat MCP access and RevenueCat skills to help configure projects, apps, products, entitlements, offerings, packages, and public SDK keys. It can also inspect project health, query chart data, and diagnose common purchase setup problems.</p>
<p><strong>Can an agent change live RevenueCat configuration?</strong></p>
<p>Yes, if your authenticated RevenueCat account has permission to make that change. Treat agent access like any other privileged product access. Review changes to projects, products, entitlements, offerings, packages, and webhooks before accepting them.</p>
<p><strong>How does authentication work?</strong></p>
<p>The plugin uses OAuth. Your agent gets access based on your RevenueCat account permissions and can work with the projects your account can access.</p>
<p><strong>Should you use this directly in production?</strong></p>
<p>Use the same judgment you would use with dashboard or API changes. For setup, testing, and debugging, start with sandbox data, test stores, or non-production projects when the change could affect live users. Confirm purchases in RevenueCat, not only on the device.</p>
<p><strong>Does this replace the RevenueCat dashboard?</strong></p>
<p>No. The dashboard remains the place to see, review, and manage RevenueCat visually. The AI Toolkit gives teams another way to work with RevenueCat: the coding agent where more app work now starts.</p>
<h2>Skills included in the AI Toolkit</h2>
<p>The AI Toolkit includes thirteen RevenueCat skills. Each one gives your agent a focused playbook for a common developer workflow.</p>
<table>
<thead><tr>
<th><p>Skill</p></th>
<th><p>Use it when you want to…</p></th>
<th><p>What your agent does</p></th>
</tr></thead>
<tbody>
<tr>
<td><p>integrate-revenuecat</p></td>
<td><p>Add RevenueCat to an app for the first time.</p></td>
<td><p>Sets up the RevenueCat side through MCP, retrieves the public SDK key, detects your app platform, installs the Purchases SDK, configures it once at launch, and verifies the SDK logs.</p></td>
</tr>
<tr>
<td><p>create-revenuecat-project</p></td>
<td><p>Bootstrap a RevenueCat project from scratch.</p></td>
<td><p>Creates or selects the project, creates platform apps, adds products, entitlements, offerings, and packages, attaches everything in the right order, and returns public API keys.</p></td>
</tr>
<tr>
<td><p>revenuecat-paywall</p></td>
<td><p>Show RevenueCat Paywalls in your app.</p></td>
<td><p>Installs the right RevenueCatUI package, presents the dashboard-configured paywall, handles callbacks, and verifies that your configured template renders instead of a fallback layout.</p></td>
</tr>
<tr>
<td><p>revenuecat-purchase-flow</p></td>
<td><p>Build a custom purchase and restore flow.</p></td>
<td><p>Fetches offerings, selects a package, calls purchase, handles cancellations and errors, exposes restore purchases, and treats entitlements as the source of truth.</p></td>
</tr>
<tr>
<td><p>revenuecat-entitlements-gate</p></td>
<td><p>Gate paid features behind subscription access.</p></td>
<td><p>Checks customerInfo.entitlements.active, listens for entitlement changes, avoids product-ID-based gating, and verifies that access updates without restarting the app.</p></td>
</tr>
<tr>
<td><p>revenuecat-identify-user</p></td>
<td><p>Connect RevenueCat identity to your auth system.</p></td>
<td><p>Wires logIn and logOut, uses stable opaque app user IDs, avoids PII, handles anonymous users, and preserves purchases when users identify later.</p></td>
</tr>
<tr>
<td><p>revenuecat-testing-setup</p></td>
<td><p>Test purchases without charging real money.</p></td>
<td><p>Picks the right testing channel, configures RevenueCat Test Store or store sandboxes, separates sandbox and production views, and verifies purchases in RevenueCat.</p></td>
</tr>
<tr>
<td><p>revenuecat-troubleshoot</p></td>
<td><p>Diagnose empty offerings, missing products, inactive entitlements, or failed sandbox purchases.</p></td>
<td><p>Reads SDK debug logs, inspects RevenueCat configuration through MCP, checks products, entitlements, offerings, packages, app IDs, and webhooks, then proposes concrete fixes.</p></td>
</tr>
<tr>
<td><p>revenuecat-status</p></td>
<td><p>Get a quick project health check.</p></td>
<td><p>Summarizes apps, products, entitlements, offerings, packages, and webhooks, then flags orphaned products, empty offerings, or apps without products.</p></td>
</tr>
<tr>
<td><p>revenuecat-charts</p></td>
<td><p>Ask questions about RevenueCat Charts and analytics.</p></td>
<td><p>Queries chart schemas and chart data, reasons about acquisition, conversion, retention, and reactivation, and builds dashboard links for the charts it references.</p></td>
</tr>
<tr>
<td><p>revenuecat-customer-center</p></td>
<td><p>Add self-service subscription management.</p></td>
<td><p>Installs and presents RevenueCat Customer Center, connects it to identified users with purchases, and verifies restore, cancel, refund, support, and dismiss callbacks.</p></td>
</tr>
<tr>
<td><p>revenuecat-migrate</p></td>
<td><p>Move from raw StoreKit or Google Play Billing to RevenueCat, or upgrade SDK major versions.</p></td>
<td><p>Chooses the migration path, uses observer mode when adopting RevenueCat alongside existing purchase code, preserves user continuity, and verifies sandbox and existing-subscriber behavior.</p></td>
</tr>
<tr>
<td><p>revenuecat</p></td>
<td><p>Handle a RevenueCat task without a more specific skill.</p></td>
<td><p>Uses the MCP server and RevenueCat docs as the fallback path.</p></td>
</tr>
</tbody>
</table>
<p>The skills stay narrow on purpose. Paywall work differs from entitlement gating. Testing differs from migration. Project creation differs from SDK configuration. Smaller playbooks make your agent easier to steer and easier to review.</p>
<h2>Get started</h2>
<p>Install the RevenueCat AI Toolkit in Claude Code:</p>
<pre><code class="language-bash">claude plugins marketplace add RevenueCat/ai-toolkit
claude plugins install RevenueCat</code></pre>
<p>Then ask your agent to set up RevenueCat, integrate the SDK, inspect your project, or debug the purchase flow that’s blocking your launch.</p>
<p><a href="https://github.com/RevenueCat/ai-toolkit">The RevenueCat AI Toolkit is available on GitHub</a> at <code>RevenueCat/ai-toolkit</code>.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[You shipped a subscription app with AI — so why does it feel like you’re losing?]]></title>
      <link>https://www.revenuecat.com/blog/growth/ai-pace-of-change</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/ai-pace-of-change</guid>
      <pubDate>Thu, 21 May 2026 12:46:34 GMT</pubDate>
      <dc:creator><![CDATA[Ethan Garr]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[The tools keep changing, but your North Star shouldn’t]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/9d6f0f422b89ba945c939b87d7ad9ddde8d26014-1600x800.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>I’m staring at a MacBook I bought on eBay a few months ago so I could run OpenClaw, and at times it’s made me feel terrible.</p>
<p>By most measures, I am in a very small group. I shipped a vibe-coded subscription app, which probably puts me in the top 1% of builders globally. And yet I feel like I’m constantly falling behind. That OpenClaw machine isn’t even powered up.</p>
<p>A few months ago, it felt like the big unlock was having a good claude.md file. Then a few weeks later it was agents. Then everyone was talking about OpenClaw. Then skills. Every couple of weeks there is a new AI thing I’m supposed to be doing.</p>
<p>I can’t keep up, and you probably can’t either. But that’s okay.</p>
<h2><strong>The reality gap</strong></h2>
<p>There’s a big gap between reality and what we’re reading on LinkedIn and X every day. Someone right now is telling you they built an app in a weekend and it made $8,000 in its first week.</p>
<p>On the other hand, <a href="https://www.revenuecat.com/blog/engineering/vibe-coding-reality-vs-hype/">I vibe-coded a mobile app</a> and it took me about four months. Then the App Store rejected it four times. It wasn’t a caffeine-fueled overnight success, but it taught me a lot and I’m still genuinely excited each time I see a new subscription trickle in.</p>
<p>To be honest, I’m a little scared to publish this. You’re going to know the truth — that I’m not keeping up. That agents aren’t doing my laundry. That my app isn’t making me rich yet.</p>
<p>Well, f*ck it.</p>
<p>Don’t misunderstand, I’m really excited about what AI is unlocking for me and for you. Whether you’re thinking about building your first app, already launched, or starting to think about growth, you should feel excited about all of it.</p>
<p>We are living in an amazing moment, and you can build awesome things. However, behind the scenes I think there’s an unspoken feeling of playing catch-up. A fear of being out-of-the-loop, or anxiety to say anything not-entirely-positive about AI. These feelings, and the opportunities AI brings, aren’t mutually exclusive. So here’s how you can do that without losing your mind as AI-pace anxiety accelerates and makes us all a bit crazy.</p>
<h2><strong>Asking AI to think for me was a bad idea</strong></h2>
<p>When I uploaded <a href="https://www.revenuecat.com/state-of-subscription-apps/">RevenueCat’s State of Subscription Apps report 2026</a> into ChatGPT with a prompt that said, “review this in detail and tell me what we should apply to OnTimer”, I knew it was bad.</p>
<p>That report is probably the most valuable thing I read each year as I help developers grow their mobile apps. And here I was phoning it in for my own app. I did read the report a few days later, but <strong>this delegation motion was becoming a bad habit</strong>.</p>
<p>I’d see a LinkedIn post about best practices for AEO and I’d delegate it to Claude Code. Someone would share a skill from their Github repo and I’d thoughtlessly dump it into Claude. And so on. <strong>No learning, just action</strong>.</p>
<p>Was the output better than nothing? Yep! In fact, often it was pretty good (LLMs are incredible). But did I learn anything? Nope!</p>
<p>I can justify it in my head: <a href="https://www.revenuecat.com/blog/engineering/vibe-coding-reality-vs-hype/">building OnTimer</a> is my side project. I’m a professional growth advisor and I have work to do; I have a daughter who I want to spend time with before she goes off to college; I am <em>busy</em>, and it feels like I’m constantly trying to jump on a moving train.</p>
<p>Regardless of how busy I was, I came to realise that outsourcing learning wasn’t making me feel good about what I was building. And <strong>delegating my thinking wasn’t making me better at growth</strong>.</p>
<p>AI is amazing at helping us speed up processes, and that’s awesome. We should embrace that. <strong>But if it is replacing learning, creativity, and curiosity what’s the point?</strong></p>
<h2><strong>There’s pressure to keep up (even when no one says it out loud)</strong></h2>
<p>The tech world has always been fast-paced. If you weren’t working long hours and sleeping on conference tables you were doing it wrong, but AI has taken that to another level.</p>
<p>There’s a new kind of pressure where it feels like if you say something negative about AI, or even just admit that you’re not using the latest tools, then you’re signaling that you’re behind. That you’re not technical enough — or worse, that you’re resisting progress.</p>
<p>So instead we leave it unsaid, and we just try to keep moving faster.</p>
<p>As I write this, Coinbase is laying off 14% of their workforce and their CEO’s letter all but says, ‘if you are not AI-first, you are last’. So I get the angst.</p>
<p>At the same time, every day I see posts that say you can’t build anything real with vibe-coding, that nobody is excited about the apps they vibe-code, and so on. Meanwhile, my vibe-coded app is available in the App Store.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/09aa526cf2f815e07f8fe4d833156bbb42fe3d19-942x2048.png" alt=""/></figure>
<p>So which is it? If no matter how fast you move and no matter how much tech you embrace, it will never be enough, <strong>how can you ever win</strong>? For a while I just tried to blindly accelerate. I bought the OpenClaw machine that still isn’t turned on. I outsourced my thinking to ChatGPT, and I tried to keep up with everything.</p>
<p>It didn’t work, <strong>and it certainly wasn’t making me smarter or happier</strong>.</p>
<h2><strong>Exit the echo chamber and find your footing</strong></h2>
<p>The loop that makes your head explode looks like this:</p>
<p>New shiny AI thing → X post says “winners are already doing this” → try to catch up → feel behind → next new shiny AI thing</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/7a92803416803687e4c60703e97cd46da0a2a3f5-1600x900.png" alt=""/></figure>
<p>When you’re in a loop like this, you don’t always realize it. For a while, <strong>the speed itself starts to feel like progress</strong>. You’re trying things, generating outputs, checking boxes, but your focus is all over the place.</p>
<p>That’s what happened to me. When I started outsourcing everything to AI, it felt like action. Things were getting done, and some of it was okay, but a lot of it wasn’t.</p>
<p>As an example, agents are awesome, and you can do some really cool things with them. But for the development phase of <a href="https://apps.apple.com/us/app/ontimer-never-be-late/id6755317601">OnTimer</a>, they were more of a distraction than a value-add. I had a simple goal to build and ship a subscription app, but I felt this heavy pressure to keep up with everything. So instead of thinking through how and why I needed agents to achieve my goal, I just started building them.</p>
<p>I was breaking my own rules:</p>
<ul>
<li>I wasn’t thinking things through</li>
<li>I wasn’t building intuition</li>
<li>I was just <strong>reacting</strong></li>
</ul>
<p>At some point, you have to stop and ask yourself a different question. Instead of asking “what am I missing?” ask <strong>“what actually matters for what I’m trying to accomplish?”</strong></p>
<p>When I focused on that, everything changed. Moving forward became more important than moving faster, and for the first time in a while, I felt like I was getting ahead.</p>
<h2><strong>Focus on meaningful things, instead of everything</strong></h2>
<p>So what do we do? We have this amazing toolset. It’s getting more amazing every day, and we have no possible way to stay on its leading edge. We need a simple playbook to help us focus, learn, and keep building.</p>
<p>For me, the same rules that apply to growing a product apply here. Focus on value, <a href="https://www.revenuecat.com/blog/growth/north-star-metrics-subscription-growth/">understand your North Star</a>, relentlessly prioritize.</p>
<p>The framework that worked for me was:</p>
<ol>
<li>Create an evaluation layer</li>
<li>Build a backlog</li>
<li>Operationalize</li>
</ol>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/8146341dca573bf85a6c008a35be3078d05d722c-1600x900.png" alt=""/></figure>
<h3><strong>1. Create an evaluation layer</strong></h3>
<p>This is easy. Right now my LinkedIn has two headlines that are piquing my interest: “Build and Run Your First MCP Server” and “1,000 AI Marketing Prompts”.</p>
<p>My North Star today for OnTimer is to <a href="https://www.revenuecat.com/blog/growth/product-market-fit-subscription-apps/">validate product-market fit</a>. I just simply look at everything I see through the lens of <strong>“Does this help me achieve the goal or is it a distraction from the goal?</strong></p>
<h3><strong>2. Build a backlog</strong></h3>
<p>Eventually emailing yourself articles and sticking post-it notes all over your desk gets overwhelming.</p>
<p>Do yourself a favor and create a repository. I just use a Google Sheet with a one sentence description of the new tool or approach, copy in links to where I can learn more, and <strong>add a score based on importance and urgency</strong> (as it relates to my North Star).</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/80e6fbc5c50d709a83e67a25e6f5eb800f1c9aa5-1369x601.png" alt=""/></figure>
<h3><strong>3. Operationalize</strong></h3>
<p>I schedule time on my calendar to dig into AI learnings each week. I even have an app that makes sure I don’t forget to do it (guess what the app is). The evaluation layer helps me focus on what I want to learn, and what the most meaningful learnings are for me right now.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/088169bd730cb9a3b9eb19d251164bf9d1fa2104-1336x2048.png" alt=""/></figure>
<p>Then my backlog helps me feel less anxious that I will miss out on things that might be important but just not super-urgent today.</p>
<p>I still offload and delegate a lot of actions to ChatGPT and Claude, but I force myself to never do that without first spending some time building a basic understanding of what the tool or system is, what it does, and why it is important.</p>
<p>The system that works for you might be different, but some version of an evaluate, backlog, operationalize approach can help you.</p>
<p>It’s not about keeping up with everything, it’s about keeping up with the right things to help you keep building what matters (including your own knowledge).</p>
<h2><strong>Take the wins, you’re further ahead than you think</strong></h2>
<p>AI should make you faster. But if it replaces the part where you actually think things through or develop your understanding, it doesn’t move you forward. <strong>It just makes you quicker at doing things that don’t move the needle</strong>.</p>
<p>Last week I was showing a friend OnTimer over Zoom. After he onboarded I watched him try to tap on a calendar event on his homescreen. When nothing happened, I realized there was a mismatch between user expectation and functionality.</p>
<p>Five minutes after the call I was in Claude Code building ‘event cards’ to fix this. I hadn’t thought through how these would actually be valuable for users, I just started building. Eventually I caught myself. I thought about my North Star and what I’m trying to achieve, and quickly realized this wasn’t the thing I should be focused on right now.</p>
<p>With AI it’s super-easy to get ‘ahead of your skis’. You can do anything, so you try to do everything. But there’s too much to consume: there are new tools, new playbooks, and new opportunities emerging faster than you can digest them. This isn’t going to slow down anytime soon, so we need to adjust our expectations of what ‘keeping up’ means in an AI-driven world.</p>
<p>The anxiety is compounded by social media which often overstates impact, over-hypes new and shiny things, and over-indexes on huge wins instead of the normal grind. There’s a lot of noise, and <strong>as a builder if you can’t cut through it, you’ll probably lose your mind</strong>.</p>
<p>AI is giving us incredible access to unlock ingenuity and innovation. If you’re building a mobile app or have already launched one, you are already in an elite group, and you should feel awesome about it. You’re further ahead than you think. Don’t let the feeling of being behind convince you that you actually are.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[The three-week COVID pivot that saved an 11-year indie business]]></title>
      <link>https://www.revenuecat.com/blog/growth/daniel-kennett-cascable-studio-launched-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/daniel-kennett-cascable-studio-launched-podcast-2026</guid>
      <pubDate>Wed, 20 May 2026 17:36:22 GMT</pubDate>
      <dc:creator><![CDATA[Charlie Chapman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Daniel Kennett nearly lost his house, his car, and his friendships building his first indie business — and then spent 11 years quietly building another one.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/255403aee45b88df5773c3cb8d1f102e27fd6c97-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p><a href="https://www.youtube.com/watch?v=yZX9s18Ho0A">Watch on YouTube</a></p>
<h3>Buying a house with RealBasic</h3>
<p>Developers love to argue about which framework is best. Daniel Kennett’s career is a reminder that users rarely care.</p>
<p>As a teenager in the UK, Daniel wanted to learn how to program a hand-me-down Mac LC2. After failing to grasp Java and C++, he found RealBasic — a cross-platform visual language. He used it to build Music Rescue, an app that let users copy music off their iPods back onto their Macs.</p>
<p>The app was born in the back of a physics class, but it hit the market at exactly the right time. When the popular website iPod Lounge used Music Rescue in a tutorial on how to back up an iPod, sales took off. “I always find it funny when people these days are like, ‘Well, you have to use X programming language or X framework to build an app,'” Daniel says. “It’s like, come on, I bought a house with RealBasic.”</p>
<p>The lesson was clear early on: if an app is polished, solves a real problem, and fits well on the platform, the underlying technology is just a detail.</p>
<h3>The human cost of going broke</h3>
<p>The success of Music Rescue meant Daniel went from working part-time in a hardware store to earning a senior developer salary while still at university. But the financial education didn’t match the income.</p>
<p>“I just buy stuff, credit card, credit card. Oh, credit card’s maxed out, pay it all off, whole thing and do that,” he recalls. When the iPhone arrived and Spotify launched, the iPod market began to shrink. Music Rescue’s revenue dipped. At the same time, Daniel and a friend started building a new app, spreading their focus too thin. Because of his spending habits and the delayed reality of credit card debt, by the time he realized the business was in trouble, it was too late.</p>
<p>“We lost a car, just suddenly came and took the car one day, fighting to keep the house and eventually sold it to avoid it being taken basically. Lost friends, everything my entire life was kind of destroyed,” he says.</p>
<p>He avoided bankruptcy “by the skin of my teeth” and took a job at a then-small Swedish startup called Spotify to rebuild his finances. The experience didn’t shake his confidence as a developer, but it fundamentally changed how he approached business risk. Years later, when he decided to go indie again with Cascable, he did it with strict financial guardrails, rolling goals, and the support of his wife, who had lived through the crash with him.</p>
<h3>Pricing for a $4,000 camera</h3>
<p>When Daniel launched Cascable Studio — an app that lets photographers remote-control non-iPhone cameras via WiFi — he faced a market that expected mobile apps to be cheap. He rejected that expectation immediately.</p>
<p>“If you can afford $4,000 for a camera, you can pay more than $2 for my app,” he says.</p>
<p>Cascable targets advanced amateurs and professional photographers who use expensive gear from Canon, Nikon, Sony, and Fujifilm. Because the app genuinely unlocks new capabilities for that hardware — like complex automation for astrophotography — the audience understands its value. The app’s non-subscription purchase option is currently $99, and Daniel notes that they rarely get complaints about the price.</p>
<p>This pricing confidence extended to how they handled the industry’s shift toward subscriptions. When Adobe moved its photography tools to a subscription-only model, it angered the entire photography community. Watching that backlash, Daniel chose to offer both a subscription and a one-time purchase option. He avoids the word “lifetime” — which creates unrealistic expectations — and instead promises that buying the non-subscription version and paying for occasional major upgrades will always be cheaper over the long run than subscribing.</p>
<p>The result? A healthy business with a paid-to-free ratio of about 25%, which is exceptionally high for a freemium app.</p>
<h3>The three-week COVID pivot</h3>
<p>Cascable Studio grew slowly and steadily for years. But in early 2020, the pandemic hit. Events were cancelled, event photographers lost their jobs, and they stopped buying photography apps. Cascable’s revenue dropped.</p>
<p>At the same time, remote work exploded, creating a massive shortage of webcams. People wanted to use their expensive DSLR and mirrorless cameras as high-quality webcams for Zoom calls.</p>
<p>Years earlier, Daniel had made an architectural decision to pull Cascable’s camera connection logic into a standalone SDK. It was originally done to license the technology to other developers, but in March 2020, it became a lifeline. Because the SDK already knew how to talk to 250 different cameras, Daniel was able to build a Mac virtual webcam app in just three weeks.</p>
<p>“From idea to money was three weeks and I’m very proud of that,” he says. The new app, Cascable Pro Webcam, filled the revenue gap left by the core app and effectively saved the company during the worst of the pandemic. It was a stark reminder that clean architecture isn’t just about code quality — it’s about business optionality.</p>
<p>In <a href="https://www.youtube.com/watch?v=yZX9s18Ho0A">the full episode</a>, Daniel also talks about his time at early Spotify, reverse-engineering the iPod using SCSI commands, why he still buys expensive cameras just to test them, and his new app PhotoScout.</p>
<h2>Guest links:</h2>
<ul>
<li><a href="https://ikennd.ac">Daniel Kennett’s Website</a></li>
<li><a href="https://cascable.se">Cascable</a></li>
<li><a href="https://mastodon.social/@iKenndac">Daniel Kennett on Mastodon</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Google I/O 2026: What’s New in Google Play and Play Billing Library 9.0]]></title>
      <link>https://www.revenuecat.com/blog/engineering/play-billing-v9</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/play-billing-v9</guid>
      <pubDate>Wed, 20 May 2026 01:39:35 GMT</pubDate>
      <dc:creator><![CDATA[Jaewoong Eum]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[This article covers Google Play’s latest AI, billing, Play Console, analytics, and protection updates, including Play Billing Library 9.0.0 changes.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/4e9055aa03074373af7cddc9c895c6c87b85f544-2280x1092.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>If you spent keynote day refreshing the <a href="https://android-developers.googleblog.com/2026/05/io-2026-whats-new-in-google-play.html">Play Billing release notes</a> wondering when v9 would land, same. It dropped on May 19, 2026, the same day as the I/O announcements, and the rest of the Play story landed in the same window. v9 itself is a smaller surface than v8 was last year (breathe out), but it stacks on top of a bigger Play story this year: AI infused discovery, churn cutting billing changes, and a Play Console that drafts your store listings for you.</p>
<h2>TLDR</h2>
<ul>
<li><strong>Play Billing Library 9.0.0 (May 19, 2026)</strong>: in app messaging for opt in price increases, blocked Play Store now returns <code>BILLING_UNAVAILABLE</code>, <code>getLinkUri</code> is <code>@Nullable</code>, target SDK 35.</li>
<li><strong>Discovery moves into Gemini</strong>: 450,000 plus movies and shows surface in the Gemini app. <a href="https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html">Engage SDK Collections</a> hits 30M MAU and 45% YoY lift in app opens.</li>
<li><strong>Play Shorts and Ask Play</strong>: short form video on store, plus a Gemini backed search overlay answering 95% of queries.</li>
<li><strong>Sidekick</strong>: AI overlay for games. No SDK work. Global rollout in summer 2026.</li>
<li><strong>AI in Play Console</strong>: CSV or Sheets driven listing pre population, agentic catalog management, AI Q and A on metrics.</li>
<li><strong>Billing aimed at involuntary churn</strong>: delayed charging for low risk users, account recovery extended 30 to 60 days, new in app subscription management API.</li>
<li><strong>Protected with Play</strong>: one dashboard for integrity, distribution, and fraud. 160M spam reviews and $3.2B in fraud blocked last year.</li>
</ul>
<p>In this article, you’ll explore each of these in turn: Gemini and Ask Play, Play Shorts and Sidekick, the AI assisted Play Console, the three billing changes that target involuntary churn, the four code level changes in Play Billing Library 9.0.0, the analytics, and Protected with Play surfaces.</p>
<h2>Discovery moves into Gemini and conversational surfaces</h2>
<p>The single biggest shift announced at I/O 2026 is that Play discovery no longer ends at the Play Store. Google is pushing app and content recommendations into the Gemini app on Android and on the web, so a query that used to land a user on a generic answer page can now deep link them into your app’s relevant screen. The first wave covers entertainment, with over 450,000 movies and TV shows surfaced through Gemini, including live sports streaming links that route directly into the partner app.</p>
<p>For developers, this means your store listing is no longer the only place Google’s recommendation engine reads. <a href="https://play.google.com/console/about/programs/EngageSDK/">The Engage SDK</a>, which already exposed in app content to Play surfaces like <a href="https://android-developers.googleblog.com/2024/07/introducing-collections-powered-by-engage-sdk.html">Collections</a> (the widget on the Android home screen that resurfaces your in app content), has grown to over 30 million monthly active users and is driving a 45% year over year lift in the number of app opens for participating titles.</p>
<p>Store listing integration ships next month, with new tablet surfaces including home screen Collections, and every Engage SDK surface now scales across 80 plus Play markets. If you publish content with structure beyond the package name (articles, episodes, products, levels), the cost of adopting Engage SDK is the same as last year and the surface you reach is now substantially larger.</p>
<p>The pattern to internalize is that any single piece of content inside your app has a real chance of being its own discovery entry point. Build deep links for everything that has a stable identifier, keep them indexed through Engage SDK, and assume the user lands on a screen mid app rather than at your home tab.</p>
<h2>Play Shorts and Ask Play change the on store experience</h2>
<p>Inside the Play Store itself, two surfaces are new. Play Shorts is a full screen, portrait, short form video feed rolling out to US users and select developers, with broader market expansion in the coming months. The feed sits next to traditional app browsing and lets you place short form promotional video against an audience that is already in store and primed to install.</p>
<p>The second surface is Ask Play, a conversational search overlay backed by Gemini. The on store Q and A feature that has been quietly running for a year already answers 95% of user queries, and Ask Play extends that with summary recommendations rendered as natural language responses to questions like “what app should I use to learn guitar.” For developers, the implication is that the keywords in your store listing are no longer the only thing that determines whether your app shows up. The descriptions, screenshots, and review text all feed Gemini’s answer construction, so listing copy now has to read well for both a human user and an LLM.</p>
<h2>Sidekick: an AI overlay for games</h2>
<p>For games specifically, Play Games Sidekick is the biggest games announcement at I/O 2026. Sidekick is an in game overlay that surfaces AI generated tips, rewards, and achievements without requiring any new SDK work from participating titles. Over 100 games have launched with Sidekick already, and Google is opening eligibility to all participating titles globally in summer 2026, along with new social features that let players track friends, share achievements, and discover what friends are playing.</p>
<p>Sidekick lives at the platform layer rather than inside each game. You opt in through Play Console rather than wiring up an SDK, and the AI tips draw on Google’s own model of the game rather than on data you ship. This keeps the integration cost low and explains how Google can promise the feature works for any participating title rather than only those who invest in custom integrations.</p>
<h2>AI in the Play Console: localization and catalog</h2>
<p>The Play Console itself got a substantial AI pass focused on two operations that consume disproportionate time for monetization teams: localizing store content and managing SKU catalogs.</p>
<p>For localization, you can now upload a CSV or Google Sheet and have the Play Console pre populate the multi language fields, with Gemini handling the translations. A separate capability turns your keyword set into listing copy automatically. Subscription benefits, which are notoriously easy to mistranslate when handed to a generic translation service because the source phrasing is short and context light, are now translated by a model that has been tuned on Play’s own corpus of subscription copy. The practical effect is that adding a new market goes from a week of agency turnaround to an afternoon of review.</p>
<p>For catalog management, the Play Console adds agentic bulk price changes, SKU import, and metadata configuration automation. The word “agentic” is literal here: the system chains operations on your behalf, ingesting a SKU list, generating regional pricing recommendations, applying them across markets, and surfacing the result for approval. Teams that maintain hundreds of SKUs across dozens of regions can move from manual entry to a review and approve workflow.</p>
<h2>Billing changes that reduce involuntary churn</h2>
<p>The billing announcements at I/O 2026 are the parts of the keynote that translate most directly into recovered revenue. Three changes work together to reduce involuntary churn, the kind that happens when a renewal payment fails and the subscription expires before the user has a chance to fix it.</p>
<p>The first is delayed charging. When a renewal payment fails and Google’s risk model classifies the user as low risk, Google Play continues to retry the charge in the background while granting the user continued access. The user does not see a payment failure dialog, the app does not lose the entitlement, and a successful retry resolves the situation transparently. From the app’s perspective the purchase stays in the <code>PURCHASED</code> state during the retry window. If a final retry fails, the standard grace period and account hold flow takes over. The risk model gates this behavior because granting access on a failed payment carries fraud risk, so the feature applies only when the model is confident the user is genuinely going to pay.</p>
<p>The second is the extended account recovery window. Account hold, the state a subscription enters when grace period runs out without a successful payment, used to last 30 days. I/O 2026 extends that window to 60 days. Google’s published figures from the extension test are an 18% reduction in involuntary churn and a 9% reduction in total churn for top developers, so smaller apps should expect more modest effects. The longer recovery window costs nothing on the developer side: the subscription state is identical, the same RTDN notifications fire, and recovery surfaces through the existing <code>SUBSCRIPTION_RESTARTED</code> event the same way it did at 30 days. Your app keeps the user’s data available longer, and your backend gets more time to surface payment fix prompts before the subscription expires.</p>
<p>The third is flexible subscription management. A new in app subscription management API lets you offer plan changes and downgrade offers at the moment of cancellation without bouncing the user out to the Play Store subscription settings screen. Prorated refunds for downgrades are handled through replacement modes on the existing subscription update flow, so a user who downgrades from annual to monthly receives the correct credit automatically.</p>
<p>The three changes compound. If your subscription business sees 5% involuntary churn per month and you adopt the extended recovery window, even a fraction of the published 18% reduction takes that to roughly 4.1%. Over a year, that translates into a larger active subscriber base for the same gross adds.</p>
<h2>Play Billing Library 9.0.0: what actually changed in code</h2>
<p>Play Billing Library 9.0.0 shipped on May 19, 2026, and if you already migrated to v8 last year, you can relax: the API surface this time is intentionally small. v8 was the breaking release. It retired the <code>SkuDetails</code> era methods (<code>querySkuDetailsAsync</code>, the no argument <code>enablePendingPurchases</code>, the legacy <code>queryPurchasesAsync</code> overload), introduced sub response codes on <code>BillingResult</code>, and renamed alternative billing to user choice billing. v9 builds on that foundation and adds a single new in app messaging capability, two compatibility adjustments, and a target SDK bump. Sub response codes like <code>PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS</code> and <code>USER_INELIGIBLE</code> carry forward from v8 unchanged.</p>
<p>The dependency update is straightforward:</p>
<pre><code class="language-kotlin">dependencies {
    val billingVersion = &quot;9.0.0&quot;
    implementation(&quot;com.android.billingclient:billing:$billingVersion&quot;)
    implementation(&quot;com.android.billingclient:billing-ktx:$billingVersion&quot;)
}</code></pre>
<p>The v9 changes that actually require code attention are in three areas.</p>
<h3>In app messaging for opt in price increases</h3>
<p>The main addition in v9 is a new category on the in app messaging API. Pre v9, <code>showInAppMessages</code> with <code>InAppMessageCategoryId.TRANSACTIONAL</code> displayed Google’s recovery prompts for users in grace period or account hold. v9 extends the same surface to display an outstanding opt in price increase, letting the user confirm the new price without ever leaving your app.</p>
<p>You call the same method you would for payment recovery:</p>
<pre><code class="language-kotlin">val params = InAppMessageParams.newBuilder()
    .addInAppMessageCategoryToShow(InAppMessageCategoryId.TRANSACTIONAL)
    .build()

billingClient.showInAppMessages(
    activity,
    params,
    object : InAppMessageResponseListener {
        override fun onInAppMessageResponse(result: InAppMessageResult) {
            when (result.responseCode) {
                InAppMessageResponseCode.NO_ACTION_NEEDED -&gt; {
                    \/\/ Nothing to show this session
                }
                InAppMessageResponseCode.SUBSCRIPTION_STATUS_UPDATED -&gt; {
                    \/\/ Payment fixed or price increase confirmed
                    refreshSubscriptionStatus(result.purchaseToken)
                }
            }
        }
    }
)</code></pre>
<p>The <code>SUBSCRIPTION_STATUS_UPDATED</code> response code covers both outcomes: payment recovery and price increase acceptance. The purchase token in the result tells you which subscription changed, and a refresh from your backend (or RevenueCat) tells you what the new state is. The two messages have different frequency caps. The price increase message shows at most once every 7 days, starting the first day the user can accept the new price. The payment issue message during grace period and account hold shows at most once per day. Either way, calling <code>showInAppMessages</code> on every app launch is safe, and the library returns <code>NO_ACTION_NEEDED</code> when there is nothing to show.</p>
<p>The practical impact is on the opt in price increase flow that previously required users to navigate to the Play Store subscription settings screen to accept a new price. Spoiler: most of them never made that trip, and their subscriptions auto canceled at the first renewal at the new price. The in app surface keeps the user inside your app and gives you a higher acceptance rate. If you have a planned price increase, adopt this first.</p>
<h3>Updated error code for a blocked Play Store</h3>
<p>The second v9 change is a reclassification of one error code. The Play Store app can be blocked by the system. This happens in OEM customized kids modes, on managed devices with parental controls, and on enterprise devices with policies that disable the store. In v8 and earlier, billing calls in this state returned a <code>BillingResult</code> with response code <code>ERROR</code> and no specific debug message. v9 reclassifies these cases as <code>BILLING_UNAVAILABLE</code> and attaches a “Play Store is blocked” debug message.</p>
<p>The migration step is a one liner in your error handling code:</p>
<pre><code class="language-kotlin">when (result.responseCode) {
    BillingResponseCode.BILLING_UNAVAILABLE -&gt; {
        if (result.debugMessage.contains(&quot;Play Store is blocked&quot;)) {
            showBlockedStoreFallback()
        } else {
            showBillingUnavailableFallback()
        }
    }
    BillingResponseCode.ERROR -&gt; {
        showGenericError()
    }
}</code></pre>
<p>Detecting this case requires <code>androidx.core</code> 1.9 or later, which most modern apps already pull transitively. If your app supports kids tablets or enterprise distribution, this reclassification lets you show a more specific message than a generic billing error and avoids a confusing dead end at the paywall.</p>
<h3>DeveloperProvidedBillingDetails.getLinkUri is now nullable</h3>
<p>The third v9 change is a nullability adjustment on developer provided billing. <code>DeveloperProvidedBillingDetails.getLinkUri()</code> now returns <code>@Nullable</code> rather than non null. The direct link URI for external payments is not always available at the payment selection stage. It sometimes resolves later in the flow. The old non null guarantee forced the library to return an empty string in those cases, and apps that tried to parse the empty string as a URI crashed at the call to <code>Uri.parse</code>.</p>
<p>The migration step is to handle both null and empty string before launching a browser intent:</p>
<pre><code class="language-kotlin">val linkUri = details.linkUri
if (!linkUri.isNullOrEmpty()) {
    val intent = Intent(Intent.ACTION_VIEW, Uri.parse(linkUri))
    startActivity(intent)
} else {
    showExternalPaymentUnavailableState()
}</code></pre>
<p>If you do not use developer provided billing, this change has no effect on your code. If you do, the migration takes a single nullness check and a fallback branch.</p>
<h3>targetSdkVersion bumped to 35</h3>
<p>v9 targets API 35 (Android 15). If your app builds against an older compile or target SDK and consumes the billing library directly, you may see manifest merger warnings until you bump your own <code>targetSdkVersion</code> to match. The library itself runs on the same <code>minSdk 23</code> floor that v8.1 established, so this is purely a target side bump.</p>
<h2>Analytics and AI powered insights</h2>
<p>The analytics surface inside Play Console got the matching upgrade. The new metrics fall into three buckets. The first is reach measurement: total visibility, store listing indirect value, and traffic source breakdowns split across engagement, retention, and monetization. The second is conversion granularity: cart conversion rates, subscriber tenure distributions, and churn reason data. The third is AI assisted interpretation: chart descriptions on the Reach and Devices and Store Performance pages, interactive Q and A on metrics you select, and proactive monetization recommendations surfaced inline.</p>
<p>The interactive Q and A is the surface most likely to change how teams use Play Console day to day. Instead of clicking through to a docs page to understand why a metric moved, you can ask the question directly in the console and Gemini answers with the chart context already loaded. This collapses the loop from observation to hypothesis to corrective action.</p>
<p>The new churn reason data deserves separate attention. Cancellation surveys have always returned reason codes, but Play Console previously surfaced them only as a flat list. The v9 console adds tenure aware breakdowns, so you can see which reasons dominate at month one versus month twelve. If your top cancellation reason at month one is “too expensive” and your top reason at month twelve is “not using it anymore,” those map to two entirely different retention interventions.</p>
<h2>Protected with Play</h2>
<p>The security side of I/O 2026 introduces Protected with Play, a centralized dashboard that consolidates integrity configuration, distribution defense, and monetization fraud controls into a single Play Console surface. The numbers Google published for the year are large: 160 million spam ratings and reviews blocked, and $3.2 billion in fraudulent and abusive transactions blocked automatically. The dashboard does not change those automated protections. What it changes is your ability to see them and configure where the thresholds sit for your app.</p>
<p>One performance related change is worth flagging. The Play Integrity API warm up latency has been reduced for latency sensitive user journeys. If you call Play Integrity at the start of a purchase flow, a sign in, or a checkout, the latency you pay for the integrity verdict is now lower. The reduction matters most for short sessions where every hundred milliseconds at the start of the flow risks losing the user.</p>
<h2>What v9 means for the v7 sunset</h2>
<p>Google’s <a href="https://developer.android.com/google/play/billing/deprecation-faq">Play Billing Library deprecation page</a> shows the August 31, 2026 deadline for new apps and updates built against v7. v7 binaries already published continue to function, but no new release built against v7 can be uploaded after that date. v9 does not change this calculation. If you are still on v7, the v9 migration guide is a single page that walks through every cumulative removal: <code>querySkuDetailsAsync</code>, the no argument <code>enablePendingPurchases</code>, the legacy <code>queryPurchasesAsync</code> overload, the user choice billing rename, and the v9 polish on top. The v7 to v8 work is the bulk of the effort. The additional v9 step is the four changes covered above and adopting the in app messaging extension for price increases.</p>
<p>Still on v7? August 31, 2026 is your hard deadline. There is no shortcut from v7 straight to v9: you do the v8 work first. Pair this article with the <a href="https://www.revenuecat.com/blog/engineering/play-billing-8-migration/">complete v7 to v8 migration guide</a>, which walks through the six migration steps, every removed API and its replacement, and the v8.1 to v8.3 additions you also pick up along the way. The four v9 changes covered above are what you tack on at the end.</p>
<p>For teams already on v8, the v9 jump is small enough to fit into a single sprint. The new in app messaging category is opt in, the error code reclassification only requires touching code that already handles <code>BILLING_UNAVAILABLE</code>, and the nullability change only affects developer provided billing integrations. The <code>targetSdkVersion</code> bump is the only forced change, and it is one line.</p>
<h2>Conclusion</h2>
<p>In this article, you’ve explored the I/O 2026 wave of Play updates: Gemini powered discovery and Ask Play moving recommendations beyond the Play Store grid, Play Shorts and Sidekick reshaping on store and in game engagement, AI assisted localization and catalog management in the Play Console, three billing additions that target involuntary churn directly, the four code level changes that make up Play Billing Library 9.0.0, and the new analytics and security surfaces.</p>
<p>The release sits in clear contrast with v8: where v8 reshaped the API surface, v9 picks up a single new capability worth adopting and consolidates the v8 foundation. For the official source material, refer to Google’s <a href="https://android-developers.googleblog.com/2026/05/io-2026-whats-new-in-google-play.html">I/O 2026 Play announcement</a>, the <a href="https://developer.android.com/google/play/billing/release-notes">Play Billing Library release notes</a>, and the <a href="https://developer.android.com/google/play/billing/migrate-pbl-latest">Play Billing Library 9 migration guide</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[This is not a playbook: how Mimo grew customer LTV by 65%]]></title>
      <link>https://www.revenuecat.com/blog/growth/optimize-funnel-metrics-mimo</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/optimize-funnel-metrics-mimo</guid>
      <pubDate>Tue, 19 May 2026 15:07:31 GMT</pubDate>
      <dc:creator><![CDATA[Ekaterina Gamsriegler]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Why optimizing one metric at a time leads to false wins — and what to do instead]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/9d673811b777fab3645f43eac5e029d23e0fe242-1600x800.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Many in the app business are looking for <em>the</em> playbook. You know the one — a canonical set of moves that will grow LTV, lower CAC, and compound into a healthy, scaling business.</p>
<p>After a decade of growing mobile apps and working at every step of the funnel as an IC, I still don’t have a playbook. What I do have by now is a good picture and a wealth of lessons on how businesses can turn around within 6–12 months, once the funnel is understood and the right problems are tackled.</p>
<p>This article isn’t a magic playbook, but I will share examples of what to watch out for when it comes to a fully-functioning funnel vs. a set of hectic moves that barely move the needle, and how to engineer growth in your favor.</p>
<h2><strong>Viewing the funnel as more than the sum of its parts</strong></h2>
<p>I’ve been lucky to have worked with a few apps at their inflection point, when the LTV/CAC math stops working, and the path forward isn’t obvious. Some we turned around in six months. Others grew MRR 10 to 25x in a year.</p>
<p>Regardless of where I’ve worked, I’ve seen a throughline: there is a <strong>dense web of interdependencies between every part of your funnel</strong>. It may sound obvious, but, in practice, it’s so easy to end up looking at the KPIs in silos instead of seeing the funnel as a <em>whole</em>.</p>
<p>A change to your paywall affects your plan distribution, which in turn affects <a href="https://www.revenuecat.com/glossary/#lifetime-value-ltv">lifetime value (LTV)</a> and ultimately determines how much you can spend on acquisition. A change to your acquisition strategy affects who enters your funnel, which in turn affects activation rates, retention, word of mouth, and the revenue that cohort generates.</p>
<p>Pull one thread, and the whole fabric moves — sometimes in the direction you wanted. So, the most valuable thing one can do is learn to manipulate how the fabric moves.</p>
<p>When I took on the growth challenge at Mimo (back then, it was an app that taught you how to code, but it has since evolved significantly), we were facing a situation that every growth-stage app eventually hits: <strong>LTV had plateaued while customer acquisition costs kept climbing</strong>.</p>
<p>Standing still wasn’t an option for a bootstrapped company. Scaling down (in the worst-case scenario) meant giving market share to competitors, losing the event volume needed to optimize campaigns, and losing data, organic downloads, and momentum. Paid acquisition was one of our core growth loops alongside organic traffic, and we had to fix the unit economics or stop spending.</p>
<p>I was fortunate to lead both product growth and marketing simultaneously, allowing me and my teams to optimize the funnel end-to-end. What I learned from that vantage point is that the critical skill lies in <strong>understanding what you’re actually testing, and what the downstream consequences might look like.</strong></p>
<h3><strong>One prerequisite for success</strong></h3>
<p>At that point at Mimo, we didn’t sit down and debate if we should ‘increase LTV’ or ‘decrease CAC’. We <em>already knew</em> from user research and data where people got stuck, what made them question the upgrade, and what made them leave. We knew that:</p>
<ul>
<li>The trial anxiety was causing friction because we read it in store reviews every day</li>
<li>Users didn’t perceive the app as a serious learning tool because we weren’t always consistent in communicating it this way</li>
<li>The language barrier was costing us because the users were vocal about it</li>
</ul>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/94e73b04cce3dc8e6f267f81d2419fab0b5d5100-512x184.png" alt=""/><figcaption>Example of one of the many research projects we were regularly conducting</figcaption></figure>
<p><strong>But we also knew our target users, what they needed, and what set us apart from the competition. </strong>If you don’t have a foundation from user research, don’t know the qualitative signals, or don’t have an honest analysis of where value breaks down, start there.</p>
<h2><strong>Interdependency #1: your paywall changes a lot downstream</strong></h2>
<p>The <a href="https://www.revenuecat.com/blog/growth/paywalls-study-guide/">paywall</a> is the most obvious lever for conversion. But it’s also a distribution mechanism: for plans, price points, user intent, ultimately LTV.</p>
<h3><strong>The trial screen that changed our trajectory</strong></h3>
<p>The paywall change that had the biggest impact at Mimo was shifting to the ‘honest trial paywall’ or ‘<a href="https://www.revenuecat.com/blog/engineering/how-to-build-a-blinkist-style-paywall-using-revenuecat-webhooks-and-zapier/">Blinkist paywall</a>’: a clear, transparent explanation of how the free trial works. When Blinkist shared <a href="https://uxplanet.org/how-solving-our-biggest-customer-complaint-at-blinkist-led-to-a-23-increase-in-conversion-b60ad514134b">the results of this experiment</a>, I wanted to test it immediately because we were seeing the same anxieties about trials in our reviews every day.</p>
<p>Our results were great: <strong>trial opt-in rates more than doubled</strong>, and <strong><a href="https://www.revenuecat.com/blog/growth/app-trial-conversion-rate-insights/">conversion from trial-to-purchase</a></strong><strong> improved by 50%</strong>. The push notifications and emails I’d set up to remind users of the trial expiration didn’t have a strong negative effect on trial cancellations, which was a relief.</p>
<p>But the downstream effect on LTV was the more interesting story: most of Mimo’s trials and purchases were happening during onboarding. Users who started a trial and didn’t cancel were our primary paying customers. The secondary segment was coming through a discount campaign later. So by dramatically increasing the number of users opting in and converting at full price, we shifted the plan mix: <strong>more users paid full price rather than a discounted rate</strong>. The improvement in <a href="https://www.revenuecat.com/glossary/#trial-conversion-rate">trial conversion rate</a> cascaded directly into an LTV lift.</p>
<p><strong>The general principle:</strong> <a href="https://www.revenuecat.com/blog/growth/paywall-redesigns-case-studies/">paywall changes</a> don’t just move trial and/or purchase rates. They shift who pays, how much, and at which stage of their journey.</p>
<h3><strong>Price changes don’t just change revenue</strong></h3>
<p>When we raised Mimo’s yearly price by 20%, the obvious effects were higher ARPU and higher subscriber LTV, as the conversion rate hadn’t changed. But pricing changes also shift how users distribute across plans. A more expensive <a href="https://www.revenuecat.com/blog/growth/annual-subscriptions-apps-pros-cons/">annual plan</a> makes the monthly plan look like a lower-commitment alternative (which most of your users need at the beginning), and affects renewal behavior down the line.</p>
<p>These distribution effects are often invisible in short-term (30-day) analysis, so you need to model them across the full cohort lifecycle.</p>
<p>I’ve also seen the reverse: prices that are way too high for where the app sits in users’ minds. Most users won’t have had a deep (or any) experience with the product by the time they hit the paywall. For better or worse, your app has already fallen into a mental category with a certain willingness-to-pay before they even open it. If the price doesn’t match that expectation, you can:</p>
<ol>
<li>Lower your price</li>
<li>Make sure you nail communicating the value through your onboarding, before the paywall</li>
<li>Do both of the above</li>
</ol>
<p>Lowering the price can sometimes be the price you need to pay to get those first paying customers in. From there, you can analyze these segments, how they use the product, and whether they derive any value from it. This paves the way to the <a href="https://www.revenuecat.com/blog/growth/product-market-fit-subscription-apps/">product-market and product-model fits</a>.</p>
<p>It’s not every app’s case, and most of the time, increasing the price is what apps end up doing, but if your users are telling you that’s why they’re not upgrading, I recommend treating it as an investment in the insights you need.</p>
<p><strong>The general principle:</strong> A price change can reshape your subscriber mix, not just your ARPU. <a href="https://www.businessofapps.com/video/the-value-of-pricing-research/">The right price</a> isn’t the highest price the market will bear, it’s the one that attracts users with the intent and means to stay.</p>
<h3><strong>Offering a trial on only one plan can change how users perceive other plans</strong></h3>
<p>Similarly, offering a free trial only on the yearly plan makes the yearly plan more attractive, which is usually the goal, as we want a higher share of yearly subscribers.</p>
<p>But depending on your target markets and demographics, <a href="https://www.revenuecat.com/blog/engineering/monthly-subscription-12-month-commitment/">users who aren’t ready to commit to a year</a>, who still feel anxious about trials (no matter how much reassurance you add), and who are unsure about the product value, might be treating the monthly plan as their trial. They subscribe for a month to test the product, cancel immediately to avoid being charged on the stores’ schedule rather than their own, and leave silently.</p>
<p>The monthly conversion numbers look fine; you see your total active subscribers number growing, but the churn rates a month later tell the real story.</p>
<p>If this sounds (or looks) like your reality, start asking your short-plan customers why they are canceling. It might be they see no other way to try out your paid plan, but believe they’ll re-subscribe later if they like it.</p>
<p><strong>The general principle:</strong> some users might treat shorter plans as a trial run. Learn if this is happening, and make sure to separate ‘real churn’ from plan upgrades.</p>
<h3><strong>Trial duration is not universal</strong></h3>
<p>Data from RevenueCat’s <a href="https://www.revenuecat.com/state-of-subscription-apps/">State of Subscription Apps </a>report shows shorter trials have huge day 0–1 cancellation spikes (over 55% for three-day trials vs. 31% for 30-day trials), but there is no universal trial length that <em>will </em>boost conversion. <a href="https://www.revenuecat.com/blog/growth/7-day-trial-subscription-app/">The right trial length</a> depends on your product’s activation curve, which is invariably linked to your app category.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/0c16975f1bd67ae661c5dcdef5b0edf64d35afd6-1921x1081.jpg" alt=""/><figcaption>Trial durations, by category — State of Subscription Apps report 2026</figcaption></figure>
<p>Put simply, three-day trials can work well for utilities and for weekly plans, where time-to-value is short, and users quickly know whether they want to continue. Seven or 14 days often work better for education and health apps, where habit formation takes more time.</p>
<p>At Mimo, we were offering 30-day trials for a long time. Trial opt-in rates were high, but by day 30, so were the <a href="https://www.revenuecat.com/blog/growth/app-cancellation-flow-best-practices/">cancellation rates</a>: users assumed they’d exhausted the content, or they hadn’t built a habit and drifted away. Shortening the trial to 14 days and communicating the depth of content more clearly helped address this.</p>
<p><strong>The general principle:</strong> trial length should match your product’s activation curve, not your optimism about how quickly users will fall in love with it. A longer trial doesn’t automatically mean better conversion. If users aren’t getting activated and retained, you’re just giving them more time to cancel.</p>
<h3><strong>Layout and design should help address purchase barriers</strong></h3>
<p>For years, <a href="https://www.youtube.com/watch?v=aJp7m4TYK7E">trial timeline screens</a> showing users exactly how and when they’d be charged were among the most successful paywall formats. But it doesn’t have to be the only one.</p>
<p>Do your users understand the trial mechanic? Is trial anxiety a barrier for them? Do they understand the difference between your free and paid plans? Are they even aware of the paid plan? Maybe your free plan is too good, and you’re not pushy with upgrades?</p>
<p>The concerns users have before upgrading should help you prioritize experiments between premium feature lists, benefit-led copy, explanations of how the trial works, and promising the reminder or social proof and cancellation policy FAQ. <strong>Start from the upgrade barriers and find a way to solve them with design and copy.</strong></p>
<p>Last year, I came up with this ‘paywall anatomy’ to visualize the multiple areas one can leverage. In my experience, most of the leverage lies in pricing and packaging.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/e529da7682f05a5dad77c104677c0eb2c2ddd5e6-993x503.png" alt=""/></figure>
<h2><strong>Interdependency #2: acquisition quality flows through the entire funnel</strong></h2>
<p>Who you acquire determines how they activate, retain, convert, and refer. Changes to your acquisition strategy don’t just affect CAC but many other <a href="https://www.revenuecat.com/blog/growth/metrics-for-scaling-paid-ads/">downstream metrics</a>, often with a delay that makes the connection easy to miss.</p>
<h3><strong>Creative concepts aren’t just for solving ‘the click problem’</strong></h3>
<p>At Mimo, our earlier ads were animated and visually playful. Hook rates and CTRs were good. But cartoon-style visuals were likely signaling ‘game’ and ‘easy’ rather than ’serious learning tool’, which subtly suppressed the intent of users who did download.</p>
<p>When we shifted toward more professional imagery and videos in ads and store listings — cleaner, expert-looking, with more prominent references to coding — we didn’t just see <a href="https://www.revenuecat.com/blog/growth/ugc-ads-apps/">top-funnel improvements</a>. The profile of users entering the funnel changed. Also, over time, this helped us shift the narrative that learning to code on mobile was not only possible but also worth paying for, before we rebranded a few years later.</p>
<p><strong>The general principle:</strong> the creative that brings someone in sets their expectations for everything that follows.</p>
<h3><strong>Seasonal campaigns and discounts can bring high-volume, but low-intent cohorts</strong></h3>
<p>Seasonal moments are real: Q5 with New Year resolutions, Black Friday sales, or Back-to-School. Conversion rates are naturally better, and revenue spikes. It feels great. But the problem can manifest as subscriber churn a few months later.</p>
<p>Users acquired during such seasons often have strong momentary motivation but lower sustained intent. I worked with a few apps where paying subscribers who started in late December would barely open the app in January. The revenue was real, but the retention wasn’t.</p>
<p>Similarly, frequent discounting generates revenue in the moment while degrading your customers’ LTV.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/106fa4f28727de074eaacdd2be68ea9910a4ebbb-591x1280.png" alt=""/></figure>
<p>Here’s the dynamic I often see when teams try to ‘manipulate’ growth by <strong>attracting users and customers via cheaper ad networks, false claims in ads, and non-stop discounts, until users develop ‘discount blindness’:</strong></p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/2c99a6b1045738bcc0001c4d299ec543855b3785-1309x331.png" alt=""/></figure>
<p><strong>The general principle:</strong> observe your subscriber cohorts over their lifetimes, not just at the outset. Check trends from previous years to see how the seasonal cohorts developed, as not all revenue is created equal.</p>
<h2><strong>Interdependency #3: what happens inside the app doesn’t stay inside the app</strong></h2>
<p>User retention doesn’t just affect users’ and customers’ lifetimes. It can also flow back up the funnel in ways that are easy to miss.</p>
<h3><strong>The language barrier was costing us across every metric</strong></h3>
<p>Years ago, we decided to <a href="https://www.revenuecat.com/blog/growth/price-localization-for-apps/">localize the Mimo app into six languages</a> after testing it in two languages. <strong>The intent was to remove the language barrier for users who were already downloading the app</strong> <strong>and had already cleared many psychological hurdles: </strong>thinking they weren’t smart enough, or young enough, or were bad at math, or that coding just wasn’t for them.</p>
<p>The positive results rippled up through the entire funnel. A better in-app experience led to higher ratings and reviews, which increased our download conversion rate and helped lower blended CAC. Better retention in core markets (D7 retention increased by +40% on average), and to a 25%-100% increase in purchase rate.</p>
<p>The lower the market’s adoption of English, the higher the <a href="https://www.revenuecat.com/blog/growth/activation-metrics/">activation metrics</a> moved. An activation and retention investment became a lever across the full funnel.</p>
<p><strong>The general principle:</strong> activation and retention improvements don’t stay in their layers. They impact CAC, organic growth, and word-of-mouth.</p>
<h3><strong>Where you invest in retention depends on where your revenue actually comes from</strong></h3>
<p>We invested significant effort into <a href="https://www.revenuecat.com/blog/growth/subscription-app-churn-reasons-how-to-fix/">managing churn and improving subscriber retention</a> by implementing payment-failure reminders, trial-expiration reminders, extensive value messaging via automated lifecycle campaigns, and in-app cancellation flows to reactivate users and customers. None of it significantly moved the needle on LTV.</p>
<p>In hindsight, this was not truly necessary. Most of our revenue at that stage came from new subscriptions, and the churn cohorts simply weren’t large enough to drive a meaningful uplift, even if we convinced them to stay.</p>
<p>These days, some of these solutions are much easier to implement. I’d recommend covering your bases with:</p>
<ul>
<li>Adding subscription benefits on Google Play</li>
<li>Utilizing <a href="https://www.revenuecat.com/blog/engineering/apple-retention-messaging-api/">Apple’s Retention Messaging API</a></li>
<li>Improving <a href="https://www.revenuecat.com/docs/platform-resources/apple-platform-resources/handling-refund-requests">handling of refund requests on the App Store</a></li>
<li>Introducing automated (or personal) emails asking for trial and purchase cancellation reasons</li>
</ul>
<p><strong>The general principle:</strong> subscriber retention optimization is the right investment when recurring revenue is a significant portion of your total revenue, or when you’ve hit your growth ceiling (when the number of churning subscribers starts exceeding the newly acquired ones). Before that, you might be solving a problem that doesn’t exist.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/95518119752065eb51e4ee546aacc15963c48d42-1070x982.png" alt=""/></figure>
<h2><strong>Interdependency #4: organic loops for lowering blended CAC</strong></h2>
<p>Viral and organic levers feel like free growth. But, most of the time, they have natural ceilings set primarily by your product category and your users’ actual behavior. And it’s worth testing at a small scale before a big investment.</p>
<h3><strong>3x our k-factor wasn’t the win it sounds like</strong></h3>
<p>We rebuilt our referral program: double-sided rewards, 14 days of free premium for both parties, simplified sharing options, and multiple bug fixes to ensure rewards are delivered. These efforts tripled our k-factor.</p>
<p>Tripling sounds significant. But, in this case, a triple from 0 was still pretty much 0. The work was directionally correct and cost-effective. We didn’t let it distract us from the primary focus after we determined it wouldn’t be a significant growth driver.</p>
<p><strong>The general principle:</strong> the ceiling is usually determined by your app’s category, your target segment, and how much they want to and can share the word about your app. Less often by the size of the reward.</p>
<h3><strong>Content sharing requires the product to be built for it</strong></h3>
<p>At Mimo, we were among the first apps to implement the share-to-stories feature, working directly with Facebook when the feature was still in beta. We embedded sharing buttons at <em>aha!</em> moments throughout the user journey, such as starting and continuing a streak, moving to a higher league, and other milestones. Many users were motivated by it and shared their progress on social media daily.</p>
<p>But I’ve seen this feature fail when working with other apps or trying to add it to other parts of the journey. It works <a href="https://www.revenuecat.com/blog/growth/ryan-jones-flighty-launched-podcast-2025/">when the product generates genuinely shareable moments</a>: when progress is visible, meaningful, and something the user actually wants to show off (for example, this was not the case with Playgrounds, where users could practice and build their projects but were far from being proud of them).</p>
<p><strong>The general principle:</strong> you can’t retrofit shareability. Sharing buttons on top of a mediocre experience just get ignored. The foundation needs to be users feeling proud of their progress.</p>
<h2><strong>What actually grew our LTV, and three questions before every experiment</strong></h2>
<p>Ultimately, over about three quarters, we managed to reverse the trend and grow cLTV by 65%, decrease paid CAC by 20%, and increase monthly proceeds by 35%. In less than 12 months, we were back on track, with a healthy blended <a href="https://www.revenuecat.com/glossary/#ltvcac-ratio">LTV/CAC ratio</a>, which we maintained for the years ahead.</p>
<p>For the record, here’s what ultimately moved the needle at Mimo:</p>
<ul>
<li><strong>The honest trial paywall</strong>: doubled trial opt-in, lifted purchase rate by 53%, and improved LTV by increasing the percentage of full-price subscribers</li>
<li><strong>Change in creative direction</strong>: changed the perception of users entering the funnel via ads and search visibility on the stores</li>
<li><strong>Localization</strong>: produced retention and conversion improvements and positive effects at the top of the funnel</li>
<li><strong>A 20% yearly plan price increase</strong>: raised ARPU and cLTV, while we monitored the distribution shift and conversion rates carefully</li>
<li><strong>Improvements in organic acquisition</strong>: extensive ASO work, as well as less meaningful initiatives such as increasing the referral program’s efficiency and implementing a content-sharing loop</li>
<li>Many other improvements that compounded</li>
</ul>
<p>None of the above are universal prescriptions. But there is a discipline: <strong>understand what you’re testing, track what it changes beyond the primary success metric, and follow the KPIs all the way down (or up) the funnel.</strong></p>
<p>Before making a change, ask:</p>
<ol>
<li>What is the expected <strong>first-order effect and primary KPIs</strong>?</li>
<li>What’s the <strong>downstream consequence and secondary KPIs</strong>: plans distribution, user intent level, cohort quality, etc.?</li>
<li>What <strong>might get worse as a result</strong> of this getting better, aka the tradeoffs?</li>
</ol>
<p>The dangerous scenario is when you optimize aggressively in one area, see a metric improve, declare a win, and miss the lagging negative effects elsewhere. Seasonal campaigns look like wins in December and early January. Heavy discounting makes you feel like a monetization genius until the cohort’s LTV matures and users start complaining.</p>
<p>There’s no playbook.</p>
<p>The apps that scale well aren’t the ones that find the best individual tactics. They’re the ones that build the clearest picture of how their funnel and value delivery actually work, then optimize with this picture in mind.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Flexible Discounts for Web Billing]]></title>
      <link>https://www.revenuecat.com/blog/company/flexible-discounts-web-billing</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/company/flexible-discounts-web-billing</guid>
      <pubDate>Mon, 18 May 2026 16:44:47 GMT</pubDate>
      <dc:creator><![CDATA[Niklas Winkels]]></dc:creator>
      <category><![CDATA[Company]]></category>
      <description><![CDATA[Create percentage-off discounts, promo codes, and win-back offers for your web subscribers.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/d8d253805289adaa1031de9eca0da948992c7dc5-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>If you want to run a seasonal sale, partner with an influencer, or win back churned subscribers, you need promo codes. But if you rely solely on the App Store or Google Play, you are fundamentally limited in what you can offer.</p>
<p>Both platforms restrict promo codes to free trials and introductory pricing. Neither supports percentage-off or fixed-amount discounts on your regular subscription price. If you want to offer 30% off to a win-back segment, the web is the only place you can do it.</p>
<p>Flexible Discounts for Web Billing is now available to all RevenueCat Web customers. You can create and manage percentage-based discounts and promo codes directly from your dashboard and apply them via URL, through the SDK, or via a code input field through the checkout UI.</p>
<h2><strong>Control your pricing on the web</strong></h2>
<p>With Flexible Discounts, you can create percentage-based discounts and manage specific discount codes (like “SUMMER30”) directly from your RevenueCat dashboard. When you create a discount, you control exactly how long it lasts.</p>
<p>You have three duration options:</p>
<ul>
<li>One-time: The discount applies only to the user’s first invoice.</li>
<li>Forever: The discount applies to all future invoices indefinitely.</li>
<li>Time-window: The discount applies to all invoices generated within a specific calendar period (e.g., all invoices within the next 3 months).</li>
</ul>
<p>The time-window duration is calendar-based, not cycle-based. If a user buys a weekly subscription with a 1-month discount, they get the discount on the initial purchase and the renewals within that month. If they buy an annual subscription with a 1-month discount, they only get the discount on the first payment.</p>
<h2><strong>How it works</strong></h2>
<p>Discounts can be applied in three ways:</p>
<ul>
<li><strong>URL parameter:</strong> Append the code to your Web Purchase Link (e.g., <code>?discount_code=SUMMER30</code>). This is ideal for email campaigns, influencer links, and targeted landing pages where the discount is pre-applied.</li>
<li><strong>SDK:</strong> Apply discounts programmatically via the RevenueCat SDK for in-app or server-side flows.</li>
<li><strong>Checkout UI:</strong> End users can enter a promo code directly in the web checkout field.</li>
</ul>
<p>You can also control exactly where the coupon field appears. Visibility is configurable per Web Purchase Link or Funnel checkout, so you only show it when it makes sense for that flow.</p>
<p>Discount codes can be scoped to specific products or applied globally across all products. Eligibility criteria can also be configured to control which users can redeem a given discount.</p>
<p>Discounts do not stack with introductory offers or trials. If a discount is applied, any configured intro offer is ignored.</p>
<p>Funnels support and discount code analytics are on the way. Head to your RevenueCat dashboard to set up your first discount code, or <a href="https://www.revenuecat.com/docs/web/web-billing/discounts">read the docs</a> to get started.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Announcing RevenueCat Kotlin Multiplatform SDK 3.0.0: a cleaner iOS setup]]></title>
      <link>https://www.revenuecat.com/blog/engineering/kmp-sdk-3</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/kmp-sdk-3</guid>
      <pubDate>Thu, 14 May 2026 21:31:36 GMT</pubDate>
      <dc:creator><![CDATA[Jaewoong Eum]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[In this article, you'll explore what changed in 3.0.0 and why, the new iOS architecture, and step-by-step migration guides.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/a416ee7655118d0cbf0c3391fa3b39855f545c85-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>The hardest part of shipping a Kotlin Multiplatform subscription app has never been Kotlin. It has been the iOS side: maintaining a Podfile alongside a Gradle build, pinning a separate <code>PurchasesHybridCommon</code> version next to your Kotlin SDK version, and reconciling two dependency graphs every time either side moves. RevenueCat’s Kotlin Multiplatform SDK 3.0.0 removes that second graph entirely.</p>
<p>The iOS integration now flows through Gradle-managed Swift package dependencies, the <code>purchases-kmp-datetime</code> add on is folded back into the main module, and the Android side jumps directly to <code>purchases-android</code> 10.x with Play Billing 8.3.0. This release also resets the minimum versions: Android 6.0 (API 23), Kotlin 2.3.20, and Compose Multiplatform 1.9.3.</p>
<p>In this article, you’ll explore what changed in 3.0.0 and why, the new iOS architecture built on the <code>kn-core</code> and <code>kn-ui</code> facade modules, the step by step migration from a 2.x project that still ships a Podfile, the datetime module removal and its <code>kotlin.time.Instant</code> replacement, the Amazon Appstore opt in change, the Android Billing 8.3.0 caveat for restored consumable purchases, and the new PostHog user ID setter.</p>
<h2><strong>What changed at a glance</strong></h2>
<p>Before 3.0.0, a Kotlin Multiplatform app using RevenueCat had two parallel native integrations. The Android target pulled in <code>purchases-hybrid-common</code> from Maven Central, which transitively pulled <code>purchases-android</code>. The iOS target pulled in <code>PurchasesHybridCommon</code> and optionally <code>PurchasesHybridCommonUI</code> through CocoaPods or Swift Package Manager, and you had to keep its version in sync with the version that your KMP release was built against. The “common files version” column in the SDK’s <code>VERSIONS.md</code> history is a long record of that constraint, and the version string on each 2.x release was a composite like <code>2.10.2+17.55.1</code> to make the pairing explicit.</p>
<p>3.0.0 collapses that pairing. The iOS target now builds directly against the native <code>purchases-ios</code> Swift package via a Kotlin/Native cinterop binding generated by Gradle. The Android target depends on <code>purchases-android</code> 10.4.0 directly, with Play Billing 8.3.0 included as a transitive dependency. There is no more “hybrid common” intermediate layer to pin, and the version table in <code>VERSIONS.md</code> shows <code>Common files version: N/A</code> for 3.0.0 for that reason.</p>
<p>The headline numbers:</p>
<table>
<thead><tr>
<th><p>Surface</p></th>
<th><p>Before (2.10.2+17.55.1)</p></th>
<th><p>After (3.0.0)</p></th>
</tr></thead>
<tbody>
<tr>
<td><p>iOS native library</p></td>
<td><p><code>PurchasesHybridCommon</code> 17.55.1 via CocoaPods or SPM</p></td>
<td><p><code>purchases-ios</code> 5.71.0 via Gradle (automatic)</p></td>
</tr>
<tr>
<td><p>iOS UI library</p></td>
<td><p><code>PurchasesHybridCommonUI</code> 17.55.1</p></td>
<td><p>bundled into <code>kn-ui</code> facade</p></td>
</tr>
<tr>
<td><p>Android library</p></td>
<td><p><code>purchases-hybrid-common</code> (transitive)</p></td>
<td><p><code>purchases-android</code> 10.4.0</p></td>
</tr>
<tr>
<td><p>Android Billing Library</p></td>
<td><p>8.0.0</p></td>
<td><p>8.3.0</p></td>
</tr>
<tr>
<td><p>Android minSdk</p></td>
<td><p>21</p></td>
<td><p>23</p></td>
</tr>
<tr>
<td><p>Kotlin</p></td>
<td><p>2.0.x</p></td>
<td><p>2.3.20</p></td>
</tr>
<tr>
<td><p>Compose Multiplatform</p></td>
<td><p>1.7.x</p></td>
<td><p>1.9.3</p></td>
</tr>
<tr>
<td><p><code>purchases-kmp-datetime</code> module</p></td>
<td><p>required for <code>Instant</code> accessors</p></td>
<td><p>removed (folded into main)</p></td>
</tr>
<tr>
<td><p>Amazon Appstore</p></td>
<td><p>implicit when using the Android target</p></td>
<td><p>opt in via <code>purchases-store-amazon</code></p></td>
</tr>
</tbody>
</table>
<p>The good news for application code: the public API surface of <code>Purchases</code>, <code>CustomerInfo</code>, <code>Offerings</code>, <code>StoreProduct</code>, and the rest of the model layer is unchanged. Most of the migration effort lives in your build files, your Xcode project, and a couple of import lines on the temporal accessors.</p>
<h2><strong>The new iOS architecture: Gradle owns the Swift dependency</strong></h2>
<p>The iOS rewrite is the largest change in this release, and it is the change that motivates almost every other one. Before walking through what to remove, it helps to understand what the new architecture actually does.</p>
<p>Two new Gradle modules ship inside the SDK: <code>kn-core</code> and <code>kn-ui</code>. The <code>kn</code> stands for Kotlin/Native, and these modules exist for one reason: to declare the iOS Swift dependency on <code>purchases-ios</code> so that Gradle can generate the Kotlin/Native cinterop bindings against it. If you examine the <code>kn-core</code> module’s build file:</p>
<pre><code class="language-kotlin">plugins {
    id(&quot;revenuecat-library&quot;)
}

kotlin {
    sourceSets {
        iosMain.dependencies {
            swiftPackage(
                path = rootProject.file(&quot;upstream\/purchases-ios&quot;),
                target = &quot;RevenueCat&quot;,
                packageName = &quot;swiftPMImport.com.revenuecat.purchases.kn.core&quot;,
                customDeclarations = &quot;&quot;&quot;
                    \/\/ Force cinterop binding generation for types otherwise not in the public API
                    static inline int __forceBindings(
                        enum RCStoreMessageType _1
                    ) { return 0; }
                &quot;&quot;&quot;.trimIndent(),
                swiftSettings = SwiftSettings {
                    define(&quot;BYPASS_SIMULATED_STORE_RELEASE_CHECK&quot;)
                }
            )

            swiftPackage(
                path = file(&quot;src\/swift&quot;),
                target = &quot;AdditionalSwift&quot;,
                packageName = &quot;swiftPMImport.com.revenuecat.purchases.kn.core.additional&quot;
            )
        }
    }
}</code></pre>
<p>Two things are worth pointing out.</p>
<p>First, the <code>swiftPackage</code> block is a custom Gradle DSL defined inside the SDK’s <code>build-logic</code>. It tells the build to compile a Swift target out of the <code>purchases-ios</code> checkout, then run <code>cinterop</code> against its generated Objective C headers, and finally expose the result under the Kotlin package <code>swiftPMImport.com.revenuecat.purchases.kn.core</code>.</p>
<p>Second, <code>purchases-ios</code> is consumed as a git submodule at <code>upstream/purchases-ios</code>. The 3.0.0 release pins that submodule to version 5.71.0. You do not need to clone it yourself when you use the published SDK artifact: the bindings are pre generated and the Swift sources are compiled when the SDK is published. From the application developer’s point of view, you simply add a Gradle dependency on <code>com.revenuecat.purchases:purchases-kmp-core</code> and everything else flows through Gradle.</p>
<p>The <code>kn-ui</code> module follows the same pattern for the SDK’s UI support, plus a Compose Multiplatform dependency for the Kotlin paywall composables:</p>
<pre><code class="language-kotlin">kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(compose.components.resources)
            implementation(compose.runtime)
        }

        iosMain.dependencies {
            swiftPackage(
                path = rootProject.file(&quot;upstream\/purchases-ios&quot;),
                target = &quot;RevenueCatUI&quot;,
                packageName = &quot;swiftPMImport.com.revenuecat.purchases.kn.ui&quot;,
                swiftSettings = SwiftSettings {
                    define(&quot;COMPOSE_RESOURCES&quot;)
                }
            )
        }
    }
}</code></pre>
<p>The practical consequence is that your Xcode project no longer needs to know about RevenueCat at all. The framework that lands in your iOS app is the same Kotlin framework that contains your shared code, and the RevenueCat symbols are already statically linked into it through the Kotlin/Native bindings. You do not import <code>PurchasesHybridCommon</code> in Swift, you do not import <code>RevenueCat</code> in Swift, you simply call the shared Kotlin API from Swift the same way you call any other shared Kotlin symbol.</p>
<h2><strong>Migration step 1: Remove the iOS hybrid common dependency</strong></h2>
<p>The first concrete change is to remove the iOS dependencies that 3.0.0 no longer needs. The instructions differ depending on whether you added them through Swift Package Manager or CocoaPods, and the SDK ships both paths in the official migration guide.</p>
<p>If you used Swift Package Manager, open Xcode, select your project in the navigator, click Package Dependencies, then select <code>PurchasesHybridCommon</code> and <code>PurchasesHybridCommonUI</code> and remove them with the minus button. There is nothing else to do on the Xcode side. The Kotlin framework that your shared module produces already pulls in <code>purchases-ios</code> through Gradle, so removing the SPM entries does not leave your iOS target without a RevenueCat dependency.</p>
<p>If you used CocoaPods, the removal is in two places. The Podfile at your iOS project root might contain:</p>
<pre><code class="language-kotlin">pod 'PurchasesHybridCommon', '17.21.2'
pod 'PurchasesHybridCommonUI', '17.21.2'</code></pre>
<p>Delete those lines and run <code>pod install</code> once to update your <code>Podfile.lock</code>. Then check your Gradle build, because the KMP project might have been using the Kotlin CocoaPods plugin to declare the same dependency:</p>
<pre><code class="language-kotlin">pod(&quot;PurchasesHybridCommon&quot;) {
    version = &quot;17.21.2&quot;
    extraOpts += listOf(&quot;-compiler-option&quot;, &quot;-fmodules&quot;)
}
pod(&quot;PurchasesHybridCommonUI&quot;) {
    version = &quot;17.21.2&quot;
    extraOpts += listOf(&quot;-compiler-option&quot;, &quot;-fmodules&quot;)
}</code></pre>
<p>These blocks are the most common source of stale state when migrating, because they live in a Gradle file rather than in the iOS project itself. Search your KMP <code>build.gradle.kts</code> files for <code>pod(</code> and remove every entry that references <code>PurchasesHybridCommon</code> or <code>PurchasesHybridCommonUI</code>. If after that removal you have no <code>pod(</code> blocks left at all, you can remove the <code>cocoapods</code> plugin and the <code>cocoapods { ... }</code> configuration block from the KMP module entirely, which also removes the need to run <code>pod install</code> as part of your KMP build.</p>
<p>The end state on iOS in 3.0.0 is a shared module that declares its iOS targets with regular <code>iosX64()</code>, <code>iosArm64()</code>, <code>iosSimulatorArm64()</code> framework configurations and no CocoaPods plugin. The SDK’s own sample app does exactly that. Its iOS source set declares the framework and nothing else:</p>
<pre><code class="language-kotlin">listOf(
    iosX64(),
    iosArm64(),
    iosSimulatorArm64()
).forEach { iosTarget -&gt;
    iosTarget.binaries.framework {
        baseName = &quot;ComposeApp&quot;
        isStatic = true
    }
}</code></pre>
<h2><strong>Migration step 2: Replace the datetime module with kotlin.time.Instant</strong></h2>
<p>The <code>purchases-kmp-datetime</code> module was a small companion artifact that added extension properties for converting the SDK’s millisecond timestamps into <code>kotlinx.datetime.Instant</code> values. Since 2.2.0+17.8.0, the recommended approach has been to use <code>kotlin.time.Instant</code> instead, and the previous <code>kotlinx.datetime</code> accessors were deprecated. In 3.0.0, the deprecated accessors are removed.</p>
<p>In 3.0.0, the same functionality lives in the main module and uses the <code>kotlin.time.Instant</code> type from the Kotlin standard library. It is annotated with <code>@ExperimentalTime</code> because that type itself is still experimental in the standard library.</p>
<p>If you upgrade through an intermediate 2.x version first, Android Studio can often auto-apply these renames via the <code>@Deprecated(ReplaceWith = ...)</code> hints. Otherwise, the replacement is mechanical. Find any usage of an <code>Instant</code> suffixed property in your code, for example <code>firstSeenInstant</code>, and rename it to drop the suffix: <code>firstSeen</code>. Then add the experimental opt in either at the file level or at the call site:</p>
<pre><code class="language-kotlin">@file:OptIn(ExperimentalTime::class)

import kotlin.time.ExperimentalTime
import kotlin.time.Instant

fun describeUser(info: CustomerInfo) {
    val seenAt: Instant = info.firstSeen
    val latest: Instant? = info.latestExpirationDate
    println(&quot;First seen at $seenAt, latest expiration $latest&quot;)
}</code></pre>
<p>The import line is the second thing to watch. <code>kotlinx.datetime.Instant</code> and <code>kotlin.time.Instant</code> are different types in different packages, so if you switch the property name but forget to switch the import, the compiler points you at the right fix immediately. Make sure your import line reads <code>import kotlin.time.Instant</code> and not the <code>kotlinx.datetime</code> one. After that, remove the <code>purchases-kmp-datetime</code> dependency from your <code>libs.versions.toml</code> and your <code>build.gradle.kts</code> files.</p>
<p>If you look at the source of <code>CustomerInfo</code> in 3.0.0, you can see how the millisecond fields and the <code>Instant</code> accessors now live side by side in the same class, with the latter computed lazily from the former:</p>
<pre><code class="language-kotlin">@ExperimentalTime
public val firstSeen: Instant by lazy {
    Instant.fromEpochMilliseconds(firstSeenMillis)
}

@ExperimentalTime
public val latestExpirationDate: Instant? by lazy {
    latestExpirationDateMillis?.let { Instant.fromEpochMilliseconds(it) }
}

@ExperimentalTime
public val originalPurchaseDate: Instant? by lazy {
    originalPurchaseDateMillis?.let { Instant.fromEpochMilliseconds(it) }
}</code></pre>
<p><code>firstSeenMillis</code>, <code>latestExpirationDateMillis</code>, and the rest of the <code>*Millis</code> companions remain public and stable. If you do not want to opt into the experimental API, you can keep using the millisecond fields directly and do your own conversion. The <code>Instant</code> accessors are a convenience layer, not a required path.</p>
<p>The same pattern applies across <code>EntitlementInfo</code>, <code>SubscriptionInfo</code>, and <code>Transaction</code>. Every temporal accessor on these classes is annotated <code>@ExperimentalTime</code>, and every one returns a <code>kotlin.time.Instant</code>. If you previously had a serialization layer that mapped <code>kotlinx.datetime.Instant</code> to a wire format, you have two options. Either map from <code>kotlin.time.Instant</code> going forward, or stay on the <code>*Millis</code> accessors and serialize the long values directly. The latter is the safer choice if your serialization library does not yet have a converter for <code>kotlin.time.Instant</code>.</p>
<h2><strong>(Optional) Migration step 3: Add the Amazon Appstore module explicitly</strong></h2>
<p>In 2.x, the Android target of <code>purchases-kmp</code> always pulled the Amazon Appstore support along with it, even if your build only ever shipped to Google Play. In 3.0.0, Amazon support is opt in and lives in its own artifact, <code>com.revenuecat.purchases:purchases-store-amazon</code>. If your KMP app does not ship to the Amazon Appstore, you do not need to add anything. If it does, the migration is a two line addition.</p>
<p>In <code>gradle/libs.versions.toml</code>:</p>
<pre><code class="language-kotlin">purchases-amazon = { module = &quot;com.revenuecat.purchases:purchases-store-amazon&quot;, version = &quot;x.y.z&quot; }</code></pre>
<p>And in your Android source set of the KMP module:</p>
<pre><code class="language-kotlin">kotlin {
    sourceSets {
        androidMain.dependencies {
            implementation(libs.purchases.amazon)
        }
    }
}</code></pre>
<p>The Amazon module is published from the <code>purchases-android</code> repository, not from <code>purchases-kmp</code>. Use the version that matches the <code>purchases-android</code> version pulled in by your KMP release. For 3.0.0, that is 10.4.0. RevenueCat’s installation docs list the current version next to the Maven coordinate, so check there if you are pinning explicitly.</p>
<p>If you forget this step on a build that previously shipped to Amazon, the symptom is a runtime check inside <code>Purchases.configure(...)</code> that complains the Amazon store is not registered. The compile passes because the SDK’s public API does not reference Amazon classes directly. It is worth running your Amazon build variant through a smoke test after the upgrade for this reason.</p>
<h2><strong>Migration step 4: Raise Android minSdk to 23 and adopt Billing 8.3.0</strong></h2>
<p>The Android side of 3.0.0 ships with Play Billing Library 8.3.0 through <code>purchases-android</code> 10.4.0. That bumps the SDK’s minimum supported Android version from API 21 to API 23 (Android 6.0). If your KMP app still advertises a <code>minSdk</code> of 21 or 22 in its Android source set, the merged manifest will fail to compile with an <code>uses-sdk:minSdkVersion 21 cannot be smaller than version 23 declared in library [com.revenuecat.purchases:purchases-android-...]</code> error.</p>
<p>The fix is to raise your <code>minSdk</code> to 23:</p>
<pre><code class="language-kotlin">android {
    defaultConfig {
        minSdk = 23
    }
}</code></pre>
<p>The device tail below API 23 has been a fraction of a percent for several years, and most app teams have already crossed this line for unrelated reasons. If you have a hard requirement to keep API 21 support, you cannot upgrade to KMP 3.0.0 yet. Stay on the 2.x line until that requirement is removed.</p>
<p>There is one behavior change inside Billing 8.x that is worth flagging even though it is technically in <code>purchases-android</code>, not in <code>purchases-kmp</code>. The 3.0.0 release notes call this out explicitly:</p>
<p>This release updates to Billing Library 8.3.0 with min SDK supported of Android 6 (API 23), previously min was 21. It also removes a previous workaround used to be able to restore consumed one time products which is not available anymore.</p>
<p>The workaround in question was a path inside the older Billing Library that allowed <code>purchases-android</code> to surface already consumed one time products when a user called <code>restorePurchases</code>. Play Billing 8.x no longer exposes consumed purchases, and there is no longer a way for the SDK to reconstruct them client side. For more detail (and the recommended approach for consumables), see the <a href="https://www.revenuecat.com/docs/known-store-issues/play-billing-library/restore-consumable-purchases-bc8#restoring-purchases-by-order-id">RevenueCat docs</a>.</p>
<h2><strong>Toolchain upgrades: Kotlin 2.3.20, Compose Multiplatform 1.9.3, Gradle 9.4.1</strong></h2>
<p>3.0.0 raises the floor of every part of its build toolchain. The SDK itself compiles on:</p>
<ul>
<li>Kotlin 2.3.20</li>
<li>Compose Multiplatform 1.9.3</li>
<li>Gradle 9.4.1</li>
</ul>
<p>You do not strictly need to match every one of these in your app, but Kotlin and Compose Multiplatform are constraints because they ship metadata that the consumer side has to be compatible with. In practice, that means you cannot consume KMP 3.0.0 on a project pinned to Kotlin 2.1 or earlier, and you cannot consume the <code>kn-ui</code> paywall composables on a project pinned to an older Compose Multiplatform that does not yet have the matching compiler plugin.</p>
<h2><strong>A complete before and after of a KMP module</strong></h2>
<p>To pull all of this together, here is what a typical KMP module’s build file looked like in 2.x with the iOS hybrid common and datetime dependencies declared:</p>
<pre><code class="language-kotlin">plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.cocoapods)
}

kotlin {
    androidTarget()
    iosX64()
    iosArm64()
    iosSimulatorArm64()

    cocoapods {
        ios.deploymentTarget = &quot;13.0&quot;
        framework { baseName = &quot;Shared&quot; }
        pod(&quot;PurchasesHybridCommon&quot;) {
            version = &quot;17.21.2&quot;
            extraOpts += listOf(&quot;-compiler-option&quot;, &quot;-fmodules&quot;)
        }
        pod(&quot;PurchasesHybridCommonUI&quot;) {
            version = &quot;17.21.2&quot;
            extraOpts += listOf(&quot;-compiler-option&quot;, &quot;-fmodules&quot;)
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.purchases.core)
            implementation(libs.purchases.datetime)
            implementation(libs.purchases.ui)
        }
    }
}</code></pre>
<p>The same module in 3.0.0 drops the <code>cocoapods</code> plugin and the <code>pod(...)</code> declarations, drops the <code>purchases-kmp-datetime</code> dependency, and adds an explicit Amazon dependency only on the Android source set if needed:</p>
<pre><code class="language-kotlin">plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.library)
}

kotlin {
    androidTarget()
    iosX64()
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        commonMain.dependencies {
            implementation(libs.purchases.core)
            implementation(libs.purchases.ui)
        }
        androidMain.dependencies {
            implementation(libs.purchases.amazon)
        }
    }
}</code></pre>
<p>The Xcode side is similarly empty of RevenueCat configuration. Your <code>Podfile</code> no longer needs <code>PurchasesHybridCommon</code> or <code>PurchasesHybridCommonUI</code> entries, and if those were the only pods you had, you can stop running <code>pod install</code> entirely.</p>
<h2><strong>Verifying the migration</strong></h2>
<p>A few checks worth running after you make the changes above:</p>
<ol>
<li>Run a full clean build on Android and iOS. The Kotlin compiler points you at any remaining <code>Instant</code> accessor that you forgot to rename, and the build fails fast if a stale <code>pod(...)</code> block still references PurchasesHybridCommon.</li>
<li>Open the merged AndroidManifest and confirm <code>com.google.android.play.billingclient.version</code> is now set to <code>8.3.0</code>.</li>
<li>On iOS, search your Xcode project for <code>PurchasesHybridCommon</code>. There should be zero hits in <code>Package.swift</code>, in your project’s Package Dependencies list, and in your Podfile. The only RevenueCat symbols visible to Swift should be those re exported through your shared Kotlin framework.</li>
<li>Run a smoke test on a real device against a sandbox account. Configure the SDK, fetch offerings, present the paywall, and complete a purchase. The 3.0.0 release ships a Maestro E2E sample under <code>e2e-tests/MaestroTestApp</code> if you want a reference for an end to end test flow.</li>
<li>If your app ships to the Amazon Appstore, confirm the Amazon variant still works. The runtime check inside <code>Purchases.configure</code> is the canary if you forgot the <code>purchases-store-amazon</code> dependency.</li>
</ol>
<h2><strong>Conclusion</strong></h2>
<p>In this article, you’ve explored the architectural shift that defines RevenueCat Kotlin Multiplatform SDK 3.0.0: you no longer need to worry about keeping the <code>PurchasesHybridCommon</code> version matched, the <code>purchases-kmp-datetime</code> module is folded into the main module behind the experimental <code>kotlin.time.Instant</code> type, the Android target ships with Play Billing 8.3.0 with a minSdk of 23, the Amazon Appstore is opt in, and the toolchain floor rises to Kotlin 2.3.20 and Compose Multiplatform 1.9.3.</p>
<p>The goal of purchases-kmp 3.0.0 is to make integrating the library as convenient as it should be. Please let us know what you think! All thoughts, comments and remarks are welcome.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[How to know if your free tier is generous enough]]></title>
      <link>https://www.revenuecat.com/blog/growth/recommendation-test-opal</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/recommendation-test-opal</guid>
      <pubDate>Thu, 14 May 2026 10:00:00 GMT</pubDate>
      <dc:creator><![CDATA[David Barnard]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Is your freemium tier just a trial in disguise?]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/7613a47229f811947c25a8718b8096641ec9fd86-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Going freemium is supposed to unlock organic growth. You give away the core product, users fall in love, and they tell their friends. But for many apps, it doesn’t play out that way. The free tier feels like a stripped-down trial, users churn before forming a habit, and the word-of-mouth engine never starts.</p>
<p>The standard freemium diagnostics don’t help much here. Conversion rate, paid penetration, and LTV — they all measure what your paying users are doing. None of them tell you whether your <em>non</em>-paying users are an asset or dead weight.</p>
<p><a href="https://www.revenuecat.com/blog/growth/kenneth-schlenker-sub-club-podcast-2026/">On a recent episode</a> of the Sub Club podcast, Opal CEO Kenneth Schlenker shared the question he uses instead: <strong>would a non-paying user recommend the app to a friend?</strong></p>
<p>If the answer is no, you need to give away more.</p>
<h2>Why this question matters more than conversion rate</h2>
<p>“If you want the freemium dynamic to really pay out, you need to make sure that the free users are recommending the app,” Kenneth says. “Otherwise, that doesn’t work.”</p>
<p>Freemium works only if free users stick around long enough to build a habit and recommend the app. If your <a href="https://www.revenuecat.com/blog/growth/freemium-tier-design/">free tier</a> is too restrictive — it’s missing core features to achieve users’ <a href="https://www.revenuecat.com/blog/growth/what-drives-users-to-pay-jobs-to-be-done/">job-to-be-done</a>, or it just feels like a countdown to a <a href="https://www.revenuecat.com/feature/paywalls">paywall</a> rather than a useful product — then neither happens. You get a small percentage converting to paid and zero organic pull.</p>
<p>That’s the trap. A free tier designed to demo the product instead of <em>be</em> a product.</p>
<p><a href="https://fast.wistia.com/medias/jo708ctaxe">Watch on Wistia</a></p>
<h2>How to actually answer the question</h2>
<p>The recommendation question is a heuristic, not a metric. But you can triangulate toward an answer by watching two things in parallel:</p>
<ol>
<li>LTV of installs</li>
<li>Overall retention across both free and paid users</li>
</ol>
<p>When Opal tested how much to give away in their core ‘blocks’ feature — the scheduled restrictions that block distracting apps during specific times — the team tried everything from one block (very restrictive) to several, measuring these two <a href="https://www.revenuecat.com/blog/growth/activation-metrics/">metrics</a> at every step.</p>
<p>The goal was to <strong>find the point where both metrics were moving up</strong>. Rather than focusing on just paid conversion, or free-to-paid funnel efficiency, this would show <strong>the combined health of the user base</strong>.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/7abc592460c0c9c3f3184555c1ff852fc012b11c-1635x962.png" alt=""/></figure>
<p>“Turns out that three [blocks] is enough for free users to get a great experience,” Kenneth explains. “They can actually really use the app, try out a few different things, and then also really power users that are convinced will pay because they want more.”</p>
<p>Three blocks gave non-payers enough <strong>utility to build a habit</strong> — and <strong>recommend the app</strong>. It also left enough headroom that committed users still had a <a href="https://www.revenuecat.com/blog/growth/how-to-turn-freemium-users-into-loyal-subscribers/"><strong>reason to upgrade</strong></a>. If LTV and retention don’t both improve as you adjust your free tier, you’re optimizing one segment at the other’s expense.</p>
<h2>What ‘yes’ actually unlocks</h2>
<p>When Opal moved from a <a href="https://www.revenuecat.com/blog/growth/hard-paywall-vs-freemium/">hard paywall to a genuinely generous free tier</a>, their pay penetration — the share of monthly active users on a paid plan — dropped from 20% to 9%. That sounds like a disaster, but it wasn’t.</p>
<p>The free tier unlocked a segment Opal couldn’t reach behind a paywall: high school and college students, who now make up two-thirds of Opal’s DAUs. These users dragged pay penetration down — but they were doing something more valuable: telling their classmates, then telling their schools. Students recommending the app to administrators created Opal for Schools, now a contracted B2B revenue line. That distribution channel didn’t exist when there was only a hard paywall.</p>
<p>Overall, the shift pushed Opal past one million daily active users. “It’s a short-term, scary drop, but what happens in the long-term is that it pays back tenfold,” Kenneth says.</p>
<h2>The takeaway</h2>
<p>If your non-payers wouldn’t recommend the product, you don’t have freemium — you have a trial in disguise.</p>
<p>The fix isn’t always to give away more. Sometimes it’s to give away differently — more of one feature, less of another, restructured so the <a href="https://www.revenuecat.com/blog/growth/deezer-sherina-khalidi-sub-club-podcast/">free experience</a> is genuinely useful rather than a teaser. But the recommendation question is the compass. Pair it with parallel tracking of LTV and overall retention, and you have a way to find the line for your own product.</p>
<p>Think about:</p>
<ol>
<li>Does it <a href="https://www.revenuecat.com/blog/growth/hard-paywall-vs-soft-paywall/">unlock enough utility</a> to build a habit or progress towards their goal?</li>
<li>Does it unlock enough to make them recommend the app?</li>
<li>Does it keep enough back to give users a reason to upgrade?</li>
</ol>
<p>Most teams <a href="https://www.revenuecat.com/blog/growth/freemium-at-scale-how-life360-built-trust-and-hit-1-8b/">optimize their free tier</a> to maximize conversion. The teams that win optimize it to maximize recommendation, and let conversion follow.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[A machine learning test doubled Life360’s top-tier subscriptions — without losing a single mid-tier user]]></title>
      <link>https://www.revenuecat.com/blog/growth/giordano-contestabile-life360-sub-club-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/giordano-contestabile-life360-sub-club-podcast-2026</guid>
      <pubDate>Wed, 13 May 2026 13:34:45 GMT</pubDate>
      <dc:creator><![CDATA[David Barnard]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Giordano Contestabile ran a test that showed degrading Life360's free tier would drive massive revenue — and then the executive team yelled at him for even suggesting it.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/41e003f01fb8a992936050860e73a9e45c78a60c-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<h2>The freemium bill of rights</h2>
<p>Six months into his role as VP of Product at Life360, Giordano Contestabile ran an experiment. The numbers came back showing a massive revenue upside. He ran to the executive team, expecting a celebration. Instead, he got yelled at.</p>
<p><a href="https://www.youtube.com/watch?v=hPwt12zZMCY">Watch on YouTube</a></p>
<p>He had violated what former CEO Chris Hulls called the “freemium bill of rights” — a core set of principles dictating that certain features, particularly those related to family safety, must never be taken away from free users to force a conversion.</p>
<p>“The reason number one why people tell us they don’t subscribe is because the free tier is good enough,” Contestabile says. “Our philosophy is that literally we want to do something about it, but that something is not making the free tier worse — is trying to provide more value and really diversify the subscription offering.”</p>
<p>For an app with nearly 100 million monthly active users, that free tier is the ultimate moat. Life360 relies heavily on network effects; the app isn’t useful in “solo mode,” and the primary discovery channel is parents telling other parents. Locking core utility behind a paywall might juice short-term revenue, but it would fundamentally break the viral loop that built the company.</p>
<h2>Why an inconclusive experiment is the only true failure</h2>
<p>To find new ways to drive revenue without degrading the free experience, Life360 had to scale its experimentation. But Contestabile doesn’t view experimentation as a series of isolated tests; he treats it as a portfolio.</p>
<p>The effectiveness of that portfolio comes down to three levers: velocity (how many experiments ship), win rate (what percentage succeed), and average win size. But surprisingly, Contestabile doesn’t mind a low win rate.</p>
<p>“The only experiments that we are sad about is an experiment that is inconclusive,” he explains. “Then we feel we wasted our time — we didn’t set up the experiment correctly or the hypothesis wasn’t right.”</p>
<p>A loss, on the other hand, is just data. It proves or disproves a hypothesis. Often, a losing experiment reveals that a feature didn’t work broadly, but resonated deeply with a specific cohort — like users in the suburbs who have been on the platform for a month and own a dog.</p>
<p>Those granular learnings feed directly into the next cycle of tests.</p>
<h2>How ML doubled Platinum subscriptions</h2>
<p>That granular approach to segmentation recently led to one of Life360’s biggest wins.</p>
<p>The app offers three subscription tiers: Silver, Gold, and Platinum. Historically, the vast majority of users chose Gold. In paywall flows, it’s difficult to clearly articulate the value of all three tiers without overwhelming the user, so the team typically defaulted to presenting the Gold option.</p>
<p>To challenge this, the team deployed a machine learning model utilizing about 900 distinct data points to identify users with a high propensity to buy the Platinum tier. When the model detected a high-propensity user, it dynamically presented the Platinum offer instead of Gold.</p>
<p>The results were staggering. “It doubled the percentage of new users subscribing to Platinum,” Contestabile says. “But it did that without losing a single gold subscriber, which was super surprising.”</p>
<p>The model successfully identified users who wouldn’t have converted on the Gold tier anyway, but were perfectly matched for the specific benefits of Platinum.</p>
<h2>Parents aren’t viral</h2>
<p>Not every data-driven bet pans out. Knowing that 40% of Life360’s users discovered the app through word-of-mouth — usually parents talking at school pickup — the team tried to digitize that behavior.</p>
<p>They built in-app referral mechanics. They offered free Silver subscriptions to users who invited friends. They tested multiple variations of sharing buttons.</p>
<p>“Nothing. Really failure across the board,” Contestabile admits. “And the reason is people my age, families, older people — they are not viral. Parents are not viral.”</p>
<p>The demographic that actually exhibits viral sharing behavior on mobile devices are teenagers. But while teens are on Life360, the parents are the ones making the purchasing and installation decisions. The team learned the hard way that you can’t force a digital referral loop onto an audience whose natural sharing behavior is entirely offline.</p>
<p>In <a href="https://www.youtube.com/watch?v=hPwt12zZMCY">the full episode</a>, Giordano and David also discuss how Life360 incorporates physical hardware like Tile into its subscription ecosystem, why the company is pushing to make growth a mandate for every department including HR and Finance, and the strategic value of the new “pet profile” feature.</p>
<p><strong>Guest links:</strong></p>
<ul>
<li>Giordano Contestabile on <a href="https://www.linkedin.com/in/gcontestabile/">LinkedIn</a></li>
<li><a href="https://www.life360.com/">Life360</a></li>
<li><a href="https://www.life360.com/careers">Life360 Careers</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Testing subscriptions on Compose Multiplatform: one test suite for iOS and Android]]></title>
      <link>https://www.revenuecat.com/blog/engineering/testing-subscription-cmp</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/testing-subscription-cmp</guid>
      <pubDate>Tue, 12 May 2026 00:22:09 GMT</pubDate>
      <dc:creator><![CDATA[Jaewoong Eum]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[In this article, you'll work through a complete subscription testing setup for a Compose Multiplatform app.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/8c990b334b2a76658ee3f103c603a1cd233af3b6-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>You wrote your subscription logic once in <code>commonMain</code>, your Compose Multiplatform paywall renders the same on iPhone and Pixel, and your <code>CustomerInfo</code> flow works on both platforms without a single <code>expect/actual</code>. Then you sit down to test it and find that the same product still has two sandboxes, two test account systems, two CI jobs, and two completely different ways for the platform to say “the purchase succeeded.” The unified codebase from <a href="https://www.revenuecat.com/blog/engineering/cmp-subscriptions/">Compose Multiplatform Subscriptions</a> stops at the test boundary, and most teams either skip purchase testing entirely or maintain two parallel test suites that drift apart.</p>
<p>In this article, you’ll work through a complete subscription testing setup for a Compose Multiplatform app, exploring why Google Play and StoreKit sandboxes resist unification, how RevenueCat’s Test Store collapses both into one sandbox, how to wire the Test Store into a KMP build with a single <code>BuildConfig</code> field, why the right testability boundary is a <code>PaywallsRepository</code> interface in <code>commonMain</code>, how to write coroutine and <code>StateFlow</code> tests in <code>commonTest</code> so a single suite runs against both targets, why fakes beat mocks for KMP subscription code, and how to layer instrumented tests on top so the same code path is exercised end to end.</p>
<p>Every snippet uses the same source layout as <a href="https://github.com/RevenueCat/cat-paywalls-kmp">cat-paywalls-kmp</a>, the official CMP demo, so you can drop these patterns into a real project without renaming anything.</p>
<h2><strong>The fundamental problem: One product, two sandboxes</strong></h2>
<p>Subscription testing on a native Android app is already an undertaking. The <a href="https://www.revenuecat.com/blog/engineering/the-ultimate-guide-to-android-subscription-testing/">ultimate guide to Android subscription testing</a> lists license testers, internal tracks, closed tracks, the <code>android.test.purchased</code> static response, and a handful of <code>BillingClient</code> quirks before you can reliably run a single purchase end to end. The equivalent path on iOS adds StoreKit configuration files, sandbox tester accounts, accelerated renewal cycles, and TestFlight rate limits that change throughout the year.</p>
<p>When the same product ships on both stores, you do not get to pick one of these paths. You run both, and you reconcile the differences yourself. A purchase that goes through in the Google Play closed track does not appear in App Store Connect. A StoreKit <code>Transaction</code> that arrives over the iOS testing pipeline does not flow through your Android <code>PurchasesUpdatedListener</code>. Even when the entire app is one Kotlin codebase, you write two <code>@Test</code> annotations, two CI matrices, and two test data setups.</p>
<p>There is also the unit test gap. Google Play does not expose any way to simulate a purchase inside a JVM unit test. StoreKit configuration files run in the iOS simulator, not in <code>kotlinx-coroutines-test</code>. So even before you worry about cross platform reconciliation, you cannot get a working purchase flow inside <code>commonTest</code> at all. The standard advice is to push everything below the SDK boundary into integration tests, which means most teams end up testing only the parts of their code that do not actually touch <code>Purchases</code>.</p>
<p>Look at the <code>CatArticlesDetailViewModel</code> from the cat-paywalls-kmp demo and you can see the shape of the problem:</p>
<pre><code class="language-kotlin">class CatArticlesDetailViewModel(
  articleId: Long,
  articlesRepository: ArticlesRepository,
  paywallsRepository: PaywallsRepository,
) : ViewModel() {

  val customerInfo: StateFlow&lt;CustomerInfo?&gt; =
    paywallsRepository.fetchCustomerInfo()
      .map { it.getOrNull() }
      .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = null,
      )
}</code></pre>
<p>This class has real business logic. It decides whether to fade an article body, whether to surface a “Join Now” CTA, what to do when the network call fails, and when to recompose. None of that logic depends on whether the underlying receipt came from Google Play or StoreKit. But if <code>PaywallsRepository.fetchCustomerInfo()</code> only emits values when a real billing SDK is connected to a real store, you cannot reach any of this logic from a unit test. The KMP win disappears at exactly the boundary where you most need it back.</p>
<p>The rest of this article is about getting it back.</p>
<h2><strong>The Test Store: A sandbox built into RevenueCat itself</strong></h2>
<p>The shortest path out of the two sandbox problem is to stop using the two sandboxes for development purchases. RevenueCat’s <a href="https://www.revenuecat.com/docs/test-and-launch/sandbox/test-store">Test Store</a> is a sandbox built into the platform itself, not into Google Play or the App Store. You configure it once in the dashboard, set a different API key in your app, and the SDK routes every purchase through RevenueCat’s own purchase modal instead of asking the native store to open its dialog.</p>
<p>The modal it shows is small and deliberate. When the user taps “Subscribe” inside the <code>Paywall</code> composable, the SDK overlays a three button sheet: <strong>Test valid purchase</strong>, <strong>Test failed purchase</strong>, and <strong>Cancel</strong>. Tapping the first one is what a successful purchase looks like from your app’s perspective. The SDK marks the receipt valid on the backend, <code>awaitPurchase</code> resumes with a <code>StoreTransaction</code>, the <code>CustomerInfo</code> flow emits a new value with <code>entitlements[&quot;premium&quot;].isActive == true</code>, and your gated UI unlocks the same way it would in production. The other two buttons exercise the failure and cancellation paths without you having to fake an exception by hand.</p>
<p>There is no Play Console setup, no App Store Connect tester account, and no license tester opt in URL. The Test Store works in the simulator, the emulator, debug builds on a real device, and CI runners. Auto renewal still happens, just on accelerated cycles: a monthly subscription renews every five minutes, an annual subscription renews every hour, both stop after five renewals so a test session never runs longer than around five hours. Every Test Store purchase shows up in the same RevenueCat dashboard as production data, which means the same <code>CustomerInfo</code> shape, the same webhook payloads, and the same entitlement transitions you ship against.</p>
<p>Two practical rules. First, you need <code>purchases-kmp</code> 2.2.2 or newer for the Test Store path; the cat-paywalls-kmp demo pins <code>2.10.2+17.55.1</code>. Second, a Test Store key is a debug only artifact. It looks like <code>test_...</code> instead of <code>goog_...</code> or <code>appl_...</code> and is rejected by the server if the SDK is configured against a production project. Treat it the same way you would treat a debug signing key: useful for development, never in a release.</p>
<h2><strong>Setting up the Test Store in a KMP project</strong></h2>
<p>The whole setup is one extra <code>BuildConfig</code> field on the Android side and one decision in your <code>Application.onCreate</code> about which key to use. iOS does not need a separate change because the same Kotlin code reads the same configuration when the shared module initializes on the Swift side.</p>
<p>Start with the Gradle build. In <code>composeApp/build.gradle.kts</code>, read the Test Store key from <code>local.properties</code> and expose it through <code>buildConfigField</code>. Pulling the key from a local property file (which is already gitignored by Android Studio) keeps it out of source control by default:</p>
<pre><code class="language-kotlin">import java.util.Properties

val localProperties = Properties().apply {
  val file = rootProject.file(&quot;local.properties&quot;)
  if (file.exists()) file.inputStream().use { load(it) }
}

android {
  namespace = &quot;com.revenuecat.catpaywalls&quot;
  defaultConfig {
    applicationId = &quot;com.revenuecat.catpaywalls&quot;
    buildConfigField(
      &quot;String&quot;,
      &quot;REVENUECAT_TEST_API_KEY&quot;,
      &quot;\&quot;${localProperties.getProperty(&quot;revenuecat.test.api.key&quot;, &quot;&quot;)}\&quot;&quot;,
    )
  }
  buildFeatures {
    compose = true
    buildConfig = true
  }
}</code></pre>
<p>Notice the default value of an empty string. If <code>local.properties</code> does not contain <code>revenuecat.test.api.key</code>, the field is still defined and compiles cleanly. That matters for two reasons: release builds pulled from a clean CI checkout do not see the key at all, and any developer who has not opted into the Test Store keeps the regular production path without any source changes.</p>
<p>The <code>local.properties</code> entry on a developer machine is one line:</p>
<pre><code class="language-text">revenuecat.test.api.key=test_YOUR_KEY_HERE
</code></pre>
<p>The key selection happens in <code>CatArticlesApplication</code>, which is the Android entry point for the shared Compose surface. The pattern is “prefer Test Store key when present, fall back to the production key otherwise”:</p>
<pre><code class="language-kotlin">class CatArticlesApplication : Application() {

  override fun onCreate() {
    super.onCreate()

    Purchases.logLevel = LogLevel.DEBUG
    val apiKey = BuildConfig.REVENUECAT_TEST_API_KEY
      .takeIf { it.isNotBlank() } ?: REVENUECAT_API_KEY

    Purchases.configure(
      PurchasesConfiguration(apiKey = apiKey) {
        appUserId = null
      },
    )
  }

  companion object {
    private const val REVENUECAT_API_KEY = &quot;your_revenuecat_api_key&quot;
  }
}</code></pre>
<p>Two design decisions here are worth pulling out. The first is that the same <code>Purchases.configure</code> call routes either to the Test Store or to production. The KMP SDK does not have a separate “test mode” flag; it picks the backend based on the key prefix and behaves identically otherwise. Your <code>commonMain</code> code never has to ask which backend it is talking to. The second is that this is a debug only ergonomics, not a build flavor. Every developer can flip on Test Store for their personal build by adding one line to their own <code>local.properties</code>, without any branches in the source tree.</p>
<p>The iOS side does not need an analogue. When the Kotlin runtime starts on iOS, it shares the same <code>Purchases.sharedInstance</code> configured by the platform <code>App</code> struct. If you want the iOS app to also use the Test Store during development, configure the same <code>test_...</code> key in the SwiftUI entry point:</p>
<pre><code class="language-kotlin">@main
struct iosAppApp: App {
    init() {
        Purchases.logLevel = .debug
        Purchases.configure(withAPIKey: testStoreApiKey ?? &quot;your_ios_api_key&quot;)
    }
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}</code></pre>
<p>Read <code>testStoreApiKey</code> from an <code>.xcconfig</code>, a <code>Info.plist</code> entry, or a build setting. The shape is the same as on Android: take the key from a local source of truth, fall back to a production constant.</p>
<h2><strong>The testability boundary: wrapping Purchases behind a repository</strong></h2>
<p>Even with the Test Store wired up, you do not want every unit test to depend on a running <code>Purchases.sharedInstance</code>. The SDK assumes a platform context that is not present in <code>commonTest</code>: an Android <code>Context</code>, a <code>BillingClient</code>, an iOS StoreKit transaction listener. The cat-paywalls-kmp demo solves this by funneling every call into the SDK through a single repository interface in <code>core/data</code>:</p>
<pre><code class="language-kotlin">interface PaywallsRepository {
  fun fetchOffering(): Flow&lt;Result&lt;Offering&gt;&gt;
  fun fetchCustomerInfo(): Flow&lt;Result&lt;CustomerInfo&gt;&gt;
  fun awaitPurchase(packageId: String): Flow&lt;Result&lt;StoreTransaction&gt;&gt;
}</code></pre>
<p>There are three things to notice about this interface. First, every method returns a <code>Flow&lt;Result&lt;T&gt;&gt;</code> instead of a suspend function. That choice gives <code>StateFlow</code> consumers something to <code>.map</code> and <code>.collect</code> without paying for an extra <code>viewModelScope.launch</code>, and it lets the implementation choose between cold flow semantics and <code>stateIn</code> caching without changing callers. Second, the types in the return positions are all from <code>com.revenuecat.purchases.kmp.models.*</code>. They are pure Kotlin data classes that exist in <code>commonMain</code>, which means a test can construct or hold them without expect/actual gymnastics. Third, there is no platform specific code in the signature, which is what lets the same interface back both <code>androidMain</code> and <code>iosMain</code> consumers.</p>
<p>The production implementation wraps <code>Purchases.sharedInstance</code> with <code>suspendCancellableCoroutine</code> and a small <code>Flow</code> builder. The shape is straightforward:</p>
<pre><code class="language-kotlin">class PaywallsRepositoryImpl : PaywallsRepository {

  override fun fetchCustomerInfo(): Flow&lt;Result&lt;CustomerInfo&gt;&gt; = flow {
    try {
      val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
      emit(Result.success(customerInfo))
    } catch (e: Exception) {
      emit(Result.failure(e))
    }
  }.flowOn(Dispatchers.IO)

  override fun fetchOffering(): Flow&lt;Result&lt;Offering&gt;&gt; = flow {
    try {
      val offerings = Purchases.sharedInstance.awaitOfferings()
      val current = offerings.current ?: error(&quot;No current offering&quot;)
      emit(Result.success(current))
    } catch (e: Exception) {
      emit(Result.failure(e))
    }
  }.flowOn(Dispatchers.IO)
}</code></pre>
<p>This is the only place in the entire codebase that imports <code>Purchases.sharedInstance</code>. Every <code>ViewModel</code>, every Compose function, every test is on the other side of the interface. That is the boundary you need to make <code>commonTest</code> viable, because anything above this line can be exercised against a fake.</p>
<h2><strong>Unit testing in commonTest: the patterns that work on both platforms</strong></h2>
<p>A Kotlin Multiplatform unit test cannot use MockK, Mockito, or any reflection based mocking framework. Those libraries are JVM only. They will compile in <code>commonTest</code> if MockK is on the version catalog, but the <code>iosTest</code> target will fail to link when Kotlin/Native tries to resolve the bytecode generation runtime. The cat-paywalls-kmp demo even declares MockK in <code>gradle/libs.versions.toml</code> and then never uses it, because the moment you adopt the convention plugin that runs <code>commonTest</code> against both targets you cannot import it from shared code.</p>
<p>What does work in <code>commonTest</code> is <code>kotlin-test</code>, <code>kotlinx-coroutines-test</code>, and Turbine. The cat-paywalls-kmp <code>KmpLibraryConventionPlugin</code> wires them into every feature module at the source set level:</p>
<pre><code class="language-kotlin">sourceSets.apply {
  commonMain.dependencies {
    implementation(libs.findLibrary(&quot;kotlinx-coroutines-core&quot;).get())
  }
  commonTest.dependencies {
    implementation(libs.findLibrary(&quot;kotlin-test&quot;).get())
    implementation(libs.findLibrary(&quot;kotlinx-coroutines-test&quot;).get())
    implementation(libs.findLibrary(&quot;turbine&quot;).get())
  }
}</code></pre>
<p>The reason this is in a convention plugin and not in each feature module’s <code>build.gradle.kts</code> is that you want this to be free. Every module that hosts a <code>ViewModel</code> or a state holder needs the same test dependencies, and forcing each module to opt in by hand is exactly the kind of friction that ends with someone writing logic they cannot reach from a test. The plugin makes “write a unit test” the default, not a setup task.</p>
<p>A feature module then only declares what is unique to its tests. For <code>feature/subscriptions</code>, the only extra dependency is <code>core/data</code>, because the test uses the real <code>PaywallsRepository</code> interface:</p>
<pre><code class="language-kotlin">plugins { id(&quot;catpaywalls.kmp.feature&quot;) }
android { namespace = &quot;com.revenuecat.catpaywalls.feature.subscriptions&quot; }
kotlin {
  sourceSets {
    commonTest.dependencies {
      implementation(projects.core.data)
    }
  }
}</code></pre>
<p>That is the entire test wiring. From here, you can write a <code>class SubscriptionManagementViewModelTest</code> in <code>feature/subscriptions/src/commonTest/kotlin/...</code> and it will be picked up by <code>androidUnitTest</code> and <code>iosTest</code> automatically. One file, both targets.</p>
<h2><strong>Why fakes beat mocks for KMP subscription code</strong></h2>
<p>With MockK off the table, your options are hand written fakes or expect/actual mock factories. The cat-paywalls-kmp demo picks fakes for every test, and the choice is more deliberate than it looks. Subscription code has two properties that make fakes the better fit even in a JVM only project. First, the surface area of <code>PaywallsRepository</code> is small. Three methods, all returning <code>Flow&lt;Result&lt;T&gt;&gt;</code>. There is nothing to mock that a small data class with a few setters cannot already express. Second, the same fake is reused across many tests with different setup, which makes a stateful builder more readable than a stack of <code>every { ... } returns ...</code> lines.</p>
<p>Here is what the canonical fake from <code>FakePaywallsRepository.kt</code> looks like:</p>
<pre><code class="language-kotlin">class FakePaywallsRepository : PaywallsRepository {
  private var offeringResult: Result&lt;Offering&gt;? = null
  private var customerInfoResult: Result&lt;CustomerInfo&gt;? = null
  private var purchaseResults: MutableMap&lt;String, Result&lt;StoreTransaction&gt;&gt; = mutableMapOf()

  fun setCustomerInfoResult(result: Result&lt;CustomerInfo&gt;?) {
    customerInfoResult = result
  }

  fun setPurchaseResult(packageId: String, result: Result&lt;StoreTransaction&gt;) {
    purchaseResults[packageId] = result
  }

  fun simulateOfferingError(message: String = &quot;Failed to fetch offering&quot;) {
    offeringResult = Result.failure(Exception(message))
  }

  fun simulateCustomerInfoError(message: String = &quot;Failed to fetch customer info&quot;) {
    customerInfoResult = Result.failure(Exception(message))
  }</code></pre>
<p>The constructor takes no arguments. The fake starts in a deliberately empty state where every flow emits a “not configured” failure. A test then sets the slots it cares about and leaves the others alone. That asymmetry is the value of fakes over mocks: you do not have to enumerate every method up front, you only describe the scenario you are testing.</p>
<p>The flow methods themselves are one line each, with the empty case routed through a recognisable exception so a misconfigured test fails loudly rather than hanging on an empty flow:</p>
<pre><code class="language-kotlin">  override fun fetchCustomerInfo(): Flow&lt;Result&lt;CustomerInfo&gt;&gt; = flow {
    emit(customerInfoResult ?: Result.failure(IllegalStateException(&quot;No customer info configured&quot;)))
  }

  override fun awaitPurchase(packageId: String): Flow&lt;Result&lt;StoreTransaction&gt;&gt; = flow {
    emit(purchaseResults[packageId] ?: Result.failure(IllegalStateException(&quot;Package not found: $packageId&quot;)))
  }
}</code></pre>
<p>The <code>IllegalStateException</code> here is not for production semantics. It is a test ergonomic. When a future contributor adds a test that calls a method without setting it up, the test fails with a message that names the missing slot, which is much faster to debug than an empty <code>Flow</code> that just never emits.</p>
<p>One limitation to call out. The fake holds and returns <code>Offering</code> and <code>CustomerInfo</code> values, but it does not construct them. Those types come from <code>purchases-kmp</code> and do not currently expose public constructors. That means you can test the failure paths through the fake directly, but a successful “purchase completed and entitlement is now active” assertion has to be exercised against a real <code>Purchases.sharedInstance</code>. That is exactly the seam the Test Store fills, and the next two sections show how.</p>
<h2><strong>Writing tests against the fake</strong></h2>
<p>A ViewModel test in <code>commonTest</code> looks almost identical to its androidx counterpart. The only differences are the <code>kotlinx.coroutines.test.StandardTestDispatcher</code> instead of <code>TestCoroutineDispatcher</code>, and Turbine instead of <code>LiveData</code> observers. Here is the setup pattern that the cat-paywalls-kmp <code>SubscriptionManagementViewModelTest</code> uses:</p>
<pre><code class="language-kotlin">@OptIn(ExperimentalCoroutinesApi::class)
class SubscriptionManagementViewModelTest {

  private val testDispatcher = StandardTestDispatcher()
  private lateinit var fakeRepository: FakePaywallsRepository

  @BeforeTest
  fun setup() {
    Dispatchers.setMain(testDispatcher)
    fakeRepository = FakePaywallsRepository()
  }

  @AfterTest
  fun tearDown() {
    Dispatchers.resetMain()
  }
}</code></pre>
<p><code>Dispatchers.setMain</code> replaces the main dispatcher that <code>viewModelScope</code> uses with the test dispatcher. Without that swap, any <code>StateFlow.stateIn(viewModelScope, ...)</code> would post to the real main thread and the test would hang, because there is no Android main looper in <code>commonTest</code>. <code>Dispatchers.resetMain</code> after each test is the symmetric tear down. Together they give you deterministic scheduling: nothing runs until you ask it to.</p>
<p>The actual test reaches into the fake, instantiates the ViewModel, and asserts on its state through Turbine. The pattern for an error path:</p>
<pre><code class="language-kotlin">@Test
fun whenCustomerInfoFetchFails_stateIsError() = runTest {
  fakeRepository.simulateCustomerInfoError(&quot;Failed to fetch customer info&quot;)
  val viewModel = SubscriptionManagementViewModel(fakeRepository)

  viewModel.uiState.test {
    assertIs&lt;SubscriptionManagementUiState.Loading&gt;(awaitItem())
    testDispatcher.scheduler.advanceUntilIdle()
    val errorState = awaitItem()
    assertIs&lt;SubscriptionManagementUiState.Error&gt;(errorState)
    assertEquals(&quot;Failed to fetch customer info&quot;, errorState.message)
    cancelAndIgnoreRemainingEvents()
  }
}</code></pre>
<p>Three details earn their keep here. The first <code>awaitItem()</code> returns the initial <code>Loading</code> value because <code>StateFlow</code> always replays its current state on collection. <code>testDispatcher.scheduler.advanceUntilIdle()</code> then drains every queued coroutine in the test scope, which is what causes the repository’s flow to emit, the ViewModel’s <code>combine</code> chain to run, and the next state to land in the <code>StateFlow</code>. The second <code>awaitItem()</code> picks that state up, and the assertion confirms the error message survives the pipeline.</p>
<p>If you write four or five tests like this, you cover the entire surface of the ViewModel’s state machine. The initial loading state, the offering error path, the customer info error path, the combined error path, and the cancellation path are each one fake setter plus an <code>awaitItem()</code>. None of these tests need a billing client, none of them need a network, and the same suite runs against the JVM target and the iOS target without modification.</p>
<p>What this suite cannot cover is the actual purchase. Because the fake cannot construct an <code>Offering</code> with real packages, you cannot run a test that says “the user taps subscribe, the SDK validates the receipt, and the entitlement flips to active.” For that path you need the Test Store, and the right place for it is an instrumented test on top of the unit suite, not inside it.</p>
<h2><strong>Integration testing with the Test Store on Android</strong></h2>
<p>The <a href="https://www.revenuecat.com/blog/engineering/testing-test-store/">Testing Test Store</a> post on the RevenueCat engineering blog walks through this end to end, and the same approach applies to a CMP project because the Android target compiles down to a normal <code>androidTest</code> source set. Put the integration test in <code>composeApp/src/androidTest/kotlin/...</code>. Configure <code>Purchases</code> once with the Test Store key, launch a small activity that drives a real purchase, and use Espresso to tap one of the three buttons that the Test Store modal presents.</p>
<p>The setup uses <code>InstrumentationRegistry.getInstrumentation().targetContext</code> because the test runs in a real Android process, not in a JVM unit test. The configuration call is the same one your <code>Application</code> makes, but pinned to the Test Store key:</p>
<pre><code class="language-kotlin">@Before
fun setup() {
  val context = InstrumentationRegistry.getInstrumentation().targetContext
  Purchases.logLevel = LogLevel.DEBUG
  Purchases.configure(
    PurchasesConfiguration.Builder(context, BuildConfig.REVENUECAT_TEST_API_KEY)
      .purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT)
      .build()
  )
}</code></pre>
<p>The actual purchase test then resolves the test offering, launches the activity, and waits for the Test Store modal to appear before tapping the “Test valid Purchase” button:</p>
<pre><code class="language-kotlin">@Test
fun successfulPurchaseUpdatesEntitlements() = runBlocking {
  val offerings = Purchases.sharedInstance.awaitOfferings()
  val pkg = offerings.all[&quot;test-offering&quot;]!!.availablePackages.first()

  activityScenario = ActivityScenario.launch(TestPurchaseActivity::class.java)
  activityScenario.onActivity { activity -&gt;
    activity.launchPurchase(pkg) { result, _ -&gt; purchaseResult = result }
  }

  delay(2_000)
  onView(withText(&quot;Test valid Purchase&quot;)).perform(click())

  withTimeout(30.seconds) {
    while (purchaseResult == null) delay(500)
  }

  assertTrue(purchaseResult!!.customerInfo.entitlements.active.isNotEmpty())
}</code></pre>
<p>Two things to call out. The <code>delay(2_000)</code> before the Espresso tap exists because the Test Store modal is rendered through the host activity, and Espresso needs the view hierarchy to settle before it can find the button. The 30 second timeout on the assertion accounts for the round trip to the RevenueCat backend that validates the receipt and updates <code>CustomerInfo</code>. Both numbers can be tuned for your CI environment, but they are intentionally generous because the test is doing real network work.</p>
<p>The companion tests are the other two buttons. Tapping “Test failed Purchase” lets you assert that the entitlement stays inactive and the error propagates through your domain layer. Tapping “Cancel” lets you assert that the <code>PurchaseCancelled</code> exception is mapped to a no op rather than surfaced as a user facing error. Three tests, three modal buttons, one Test Store key, no Play Console.</p>
<h2><strong>The iOS side: StoreKit configuration files and what the Test Store unifies</strong></h2>
<p>On iOS, the equivalent of an instrumented test is a StoreKit configuration file driving an XCUITest. Apple introduced <a href="https://www.revenuecat.com/docs/test-and-launch/sandbox/apple-app-store">StoreKit configuration files</a> in Xcode 12, and they remain the canonical way to test purchases inside the iOS simulator without setting up a sandbox account. You describe the products in a <code>.storekit</code> file, attach it to a scheme, and run the app under that scheme. Purchases then resolve through the configuration file instead of the App Store backend, and you can use XCUITest to drive the resulting native StoreKit modal.</p>
<p>This works, and for an iOS only app it is often the right answer. In a Compose Multiplatform project, the trade off looks different. The StoreKit configuration file only covers the iOS side. It does not interact with your Android tests, it does not flow data through your <code>PaywallsRepository</code>, and a successful purchase against it does not show up in the RevenueCat dashboard the way a real purchase does. You end up with two parallel integration suites that test the same Kotlin code through two completely different driver layers.</p>
<p>The Test Store collapses this. When the same <code>commonMain</code> code is configured against a Test Store key on both platforms, an iOS purchase goes through the same RevenueCat modal as an Android purchase, the same backend validates the receipt, and the same <code>CustomerInfo</code> flow emits the same shape on both sides. Your iOS integration test can then drive the same three button modal with XCUITest:</p>
<pre><code class="language-kotlin">func testSuccessfulPurchase() throws {
    let app = XCUIApplication()
    app.launch()
    app.buttons[&quot;Subscribe&quot;].tap()
    app.buttons[&quot;Test valid Purchase&quot;].tap()

    let unlockedHeader = app.staticTexts[&quot;Premium Article&quot;]
    XCTAssertTrue(unlockedHeader.waitForExistence(timeout: 30))
}</code></pre>
<p>The pattern mirrors the Android Espresso test almost line for line. Find the subscribe button, find the “Test valid Purchase” button, wait for the gated content to unlock. The same three Test Store buttons cover the same three scenarios. The same <code>CustomerInfo</code> update unlocks the same composable. You still write two driver layers because the OS testing frameworks are different, but the data plane is shared, the network calls are the same, and the dashboard sees both runs as the same Test Store activity.</p>
<p>For most teams, this is enough. If you also want to exercise the native StoreKit error paths (parental controls blocking a purchase, sandbox account expired, region restrictions), keep a small <code>.storekit</code> based suite for those edge cases. The bulk of your purchase path coverage moves to the Test Store and shrinks the size of the iOS only suite to a handful of platform specific tests.</p>
<h2><strong>A three layer testing strategy</strong></h2>
<p>Putting it all together, a Compose Multiplatform subscription app has three useful test layers, and each one answers a different question.</p>
<p>The first layer is <code>commonTest</code> unit tests against <code>FakePaywallsRepository</code>. This is where you cover your ViewModel state machine: initial loading, error paths, cancellation handling, derived UI states, and any business logic that lives above the SDK boundary. These tests are fast, deterministic, run on both targets from a single source set, and never touch the network. They are the layer you run on every PR, in pre push hooks, and on every CI build.</p>
<p>The second layer is instrumented tests against the Test Store. On Android this is <code>androidTest</code> with Espresso. On iOS this is XCUITest. Both drive the same RevenueCat purchase modal, both produce real <code>CustomerInfo</code> updates, and both verify the end to end flow from subscribe button to unlocked content. These tests are slower and a little flakier because they include real network calls, but they are the only layer where you can prove that the purchase path works for a real receipt. Run them on merges to main, on release branches, and as part of your release qualification.</p>
<p>The third layer is optional and platform specific. A StoreKit configuration file based suite for iOS edge cases that the Test Store does not model. A Google Play closed track for the rare bugs that only reproduce against the real billing client. Both are valuable, but neither is the workhorse. They exist to plug specific gaps, not to cover the core purchase flow.</p>
<p>The numbers shift the right way too. The unit tests are tens of milliseconds each and run on every commit. The instrumented Test Store tests are seconds each and run on merge. The native edge case suites are minutes each and run on release. Most of your purchase coverage ends up in the layer that runs most often, which is the opposite of what happens when you only have a <code>closedTrack</code> and a StoreKit configuration file.</p>
<h2><strong>CI considerations: one matrix, three jobs</strong></h2>
<p>The CI shape that falls out of this is straightforward. One job runs <code>commonTest</code> for both targets:</p>
<pre><code class="language-javascript">- name: Run unit tests (JVM + iOS)
  run: .\/gradlew allTests</code></pre>
<p><code>allTests</code> is the umbrella task that Kotlin Multiplatform creates when you have both Android and iOS targets configured. It dispatches to <code>jvmTest</code>, <code>iosX64Test</code>, <code>iosSimulatorArm64Test</code>, and any other target you have enabled. A failure in any one fails the job. Run this on every push, every pull request, and every nightly build.</p>
<p>The second job runs the Test Store instrumented tests on Android. The blog’s CI snippet works as written:</p>
<pre><code class="language-javascript">- uses: reactivecircus\/android-emulator-runner@v2
  with:
    api-level: 30
    target: google_apis
    arch: x86_64
    script: .\/gradlew :composeApp:connectedAndroidTest</code></pre>
<p>Two practical notes. The Test Store key needs to be available to this job, but not to forks or to release builds. The cleanest way is a repository secret that the job writes to <code>local.properties</code> at the start of the run, so the same <code>buildConfigField</code> pattern picks it up. The job should also run on merges to main and on release branches, not on every PR, because it includes real network calls and consumes Test Store renewals.</p>
<p>The third job runs the iOS XCUITests. A standard <code>xcodebuild test</code> invocation against the same scheme that ships the app, with the Test Store key injected through the same mechanism your local builds use. Both Android and iOS integration jobs read from the same RevenueCat project, which means a single dashboard view shows test activity from both platforms.</p>
<p>The result is a CI surface that mirrors the test layers. Fast feedback from the unit suite on every commit. Slower but realistic feedback from the Test Store suite on merge. The expensive native suites only when you cut a release. None of these jobs require coordinating two sandboxes, and the same <code>PaywallsRepository</code> and <code>ViewModel</code> code is exercised end to end at every layer.</p>
<h2><strong>Conclusion</strong></h2>
<p>In this article, you’ve worked through a complete subscription testing setup for a Compose Multiplatform app. You configured the RevenueCat Test Store from <code>local.properties</code> and a single <code>buildConfigField</code>, drew the testability boundary at a <code>PaywallsRepository</code> interface so the SDK never leaks into your ViewModels or your tests, wrote <code>commonTest</code> unit tests against a <code>FakePaywallsRepository</code> using <code>kotlin-test</code>, <code>kotlinx-coroutines-test</code>, and Turbine, then layered instrumented tests on top with Espresso on Android and XCUITest on iOS that drive the same three button Test Store modal. The same shared code path is exercised from <code>commonTest</code> all the way through to the receipt validation backend, on both platforms, without two parallel test suites.</p>
<p>The thing worth internalizing is what changed at each layer. Subscription testing usually fragments because the sandboxes do. By moving the development sandbox up into RevenueCat itself, the Test Store turns the two platform problem back into one. By putting a small repository interface between <code>Purchases.sharedInstance</code> and the rest of your app, <code>commonTest</code> becomes a viable home for the bulk of your business logic tests. By using fakes instead of mocks at the unit layer, the same suite runs against both Kotlin targets without expect/actual or a JVM only mocking framework. Each of these choices is small. Together they collapse what used to be two test suites into one.</p>
<p>Whether you are porting an Android subscription app to iOS, starting a new CMP product from scratch, or trying to get purchase testing into CI for the first time, this layered setup gives you the smallest surface area you can ship a cross platform subscription app on with real confidence. The full source for every snippet in this article lives in <a href="https://github.com/RevenueCat/cat-paywalls-kmp">cat-paywalls-kmp</a>, and a free RevenueCat project with a Test Store key is the only thing you need to run all three layers locally.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Revenge of the brand manager: why ‘pretty’ ads fail at direct response]]></title>
      <link>https://www.revenuecat.com/blog/growth/direct-response-creative</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/direct-response-creative</guid>
      <pubDate>Mon, 11 May 2026 14:42:25 GMT</pubDate>
      <dc:creator><![CDATA[Shamanth Rao]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Why you should be optimizing for live experience over aesthetics]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/ee94e5c0108db2c420edcbd0d5beb225fb91887e-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>There’s a persistent temptation in app growth to make things look beautiful. When a team finally gets budget for creative production, the instinct is to hire a slick agency, shoot in 4K, and write a punchy, conceptual slogan. It feels like real marketing — like you’re finally building a brand.</p>
<p>But when those ads hit the Facebook and Instagram feeds, they often bomb.</p>
<p>I framed this pattern <a href="https://www.youtube.com/watch?v=Wj9d0ej3rmk">during a recent Sub Club Live</a> as “revenge of the brand manager”. This happens when you optimize for aesthetics rather than addressing the messy, stressful reality your users actually inhabit.</p>
<h2>Selling a vibe instead of a solution</h2>
<p>Take a recent ad campaign from Granola, a popular AI note-taking app. Granola’s ads were undeniably gorgeous — exactly what a brand manager wants to see on a billboard.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/40f4dd1171d0a81ebe73766989c0cba7e302d858-1199x612.png" alt=""/></figure>
<p>The problem? The ads didn’t speak to the user.</p>
<p>When I look at creative like this, I see an ad made by somebody who hasn’t taken the time or trouble to really understand the world their users inhabit. While it checks a lot of boxes for traditional advertising best practices, in direct response advertising, it doesn’t really speak to what the users are going through.</p>
<p>When a user is scrolling through Instagram or TikTok, a conceptual slogan doesn’t stop them. They don’t care about your brand identity — they care about their own immediate problems. If your ad looks like it was made simply to be beautiful, it registers as an ad. An interruption rather than a solution. As we’ve seen with the shift toward <a href="https://www.revenuecat.com/blog/growth/ugc-ads-apps/">value-first UGC ads</a>, users have developed a profound blindness to anything that feels overly produced or disconnected from their reality.</p>
<h2>Designing for the lived experience</h2>
<p>If beautiful slogans don’t work, what does? The fix is to root your creative entirely in the user’s lived experience. You have to depict a specific scenario that the user recognizes immediately.</p>
<p>For a note-taking app like Granola, I would suggest focusing on a visceral moment of panic.</p>
<p>Take a specific instance from your user’s life, like a CEO asking what the client said. You freeze. But then you look at this app, and remember your notes. That kind of lived experience ad works much, much better versus a slogan where I don’t even know what the product does for anyone.</p>
<p><a href="https://fast.wistia.com/medias/dehf64pfbs">Watch on Wistia</a></p>
<p>This approach works because it leverages the <a href="https://www.revenuecat.com/blog/growth/solve-app-problems-emotionally/">emotion that comes with a solved problem</a>. The user isn’t buying an AI note-taker; they’re buying an escape hatch for when their boss puts them on the spot. By visualizing the panic of the problem and the relief of the solution, the ad earns the click.</p>
<h2>Why ‘ugly’ often wins</h2>
<p>This reality is often frustrating for founders and designers. As David pointed out during our live session, it is easy to err on the side of polish because we all love the ideal of pristine brand advertising. But in direct response, the ‘ugly’ design — the one that clearly and bluntly states the problem — is frequently what wins the <a href="https://www.revenuecat.com/blog/growth/subscription-app-creative-testing/">creative testing</a> phase.</p>
<p>When you’re scaling <a href="https://www.revenuecat.com/blog/growth/web-to-app-paid-user-acquisition/">paid user acquisition</a>, you cannot afford to let aesthetic preferences dictate strategy. If you <a href="https://www.revenuecat.com/blog/growth/overanalyze-creative-analysis-paid-ads/">overanalyze your creative</a> through the lens of brand purity, you miss the scrappy, relatable angles that actually drive downloads and <a href="https://www.revenuecat.com/blog/growth/7-day-trial-subscription-app/">trial starts</a>.</p>
<p>So the next time you brief a designer or a creator, ban the conceptual slogans. Ask them to show you the moment your user sweats, and the moment your app makes them breathe a sigh of relief.</p>
<p><em>About the Author: Shamanth Rao is the founder and CEO of </em><a href="https://www.rocketshiphq.com/"><em>RocketShip HQ</em></a><em>, a boutique growth marketing firm with over $100 million in managed spend. He hosts the </em><a href="https://mobileuseracquisitionshow.com/"><em>Mobile User Acquisition Show</em></a><em>, where he’s launched Intelligent Artifice, a deep dive on how AI is transforming performance marketing.</em></p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Meet the subscription app pre-mortem: how to plan for failure before you ship]]></title>
      <link>https://www.revenuecat.com/blog/growth/subscription-app-pre-mortem</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/subscription-app-pre-mortem</guid>
      <pubDate>Fri, 08 May 2026 10:45:34 GMT</pubDate>
      <dc:creator><![CDATA[Daphne Tideman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Why pessimistic planning leads to optimistic launches]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/68c564332be5730ce0ae8d71355578821c03a958-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>When I started out in the app industry, I worked at a growth agency that partnered with a development agency. As a result, a lot of our projects involved launching entirely new websites or app redesigns.</p>
<p>It was amazing — a fresh slate to use all our customer insights and improve performance! …But also slightly terrifying. Not because of the work itself, but because of the waiting.</p>
<p>That horrible in-between phase after you’ve shipped something, where you’re refreshing dashboards, overanalyzing early feedback, and catastrophizing every dip in the numbers.</p>
<p>What helped me wasn’t positive thinking. It was the opposite: leaning into pessimism.</p>
<p>Deliberately imagining everything that could go wrong <em>before</em> launch. Thinking through every possible scenario. Having the trickiest, most uncomfortable conversations with clients and teams to explore all the what-ifs.</p>
<p>It’s called a <strong>pre-mortem</strong>. (A bit morbid, but it works.)</p>
<p>Since then, I’ve used it for everything from website redesigns to <a href="https://www.revenuecat.com/blog/growth/guide-to-mobile-paywalls-subscription-apps/">subscription app launches</a>; especially in products where feedback loops are fast and the stakes compound quickly.</p>
<p>And it’s become one of my favorite planning tools.</p>
<h2>What is a pre-mortem?</h2>
<p>A regular post-mortem happens after something fails, the classic sprint retrospective. You gather the team, ask not only ‘what went right’ but also ‘what went wrong’ or ‘what could have gone better’, and try to learn from it.</p>
<p>It often comes with a few cringe-inducing <em>“we could have predicted that”</em> moments, where Captain Hindsight suddenly looks like a genius.</p>
<p>The problem is, <strong>it’s reactive rather than proactive</strong>. You’re analyzing what went wrong after the fact, but it doesn’t change the outcome.</p>
<p>A pre-mortem flips that. The concept is simple: before you launch, imagine it’s three months later, and the launch has completely flopped. Really put yourselves in that moment: the disappointment, the stress, and the why-is-this-happening panic. Bring it to life.</p>
<p>Then ask the full team: <em><strong>why did it fail?</strong></em></p>
<p>I called it pessimistic, but honestly, it’s not. It only becomes pessimistic if you assume failure is inevitable and stop there. The real goal is to <strong>surface risks while you can still do something about them</strong>.</p>
<p>If anything, it’s more realistic — and even optimistic — because it assumes you <em>can</em> fix and prevent those risks.</p>
<p>And the interesting part? The things that come up in a pre-mortem are rarely surprising. They’re usually the things everyone already half-knows but hasn’t said out loud.</p>
<h2>Who came up with such a morbid concept?</h2>
<p>The credit goes to Gary Klein and his often-cited <a href="https://hbr.org/2007/09/performing-a-project-premortem">Harvard Business Review article from 2007</a>. But Gary (and yes, given how much I love this concept, we’re absolutely on a first-name basis) had been using the method for a good 30 years before that. He first developed it in the early 90s, so no one needs to do confusing maths back from 2007.</p>
<p>A psychologist by trade, he was fascinated by how people make decisions under pressure and uncertainty. He studied this in one of the most high-stakes environments imaginable: the US Air Force. Later, he developed the idea of pre-mortems further within his own research company, drawing not only on that work but also on medical post-mortems.</p>
<p>What Gary understood — and what more of us need to recognize — is the flawed reality of group decision-making. We’re wired for harmony. We want to get along. No one wants to be the negative Nancy, pointing out risks or poking holes in a plan.</p>
<p>I recently emailed a client outlining all the risks in their current target-setting model and ended with, “sorry, this is a bit negative…”.I felt bad raising those risks, but I also knew, from experience, what happens when you don’t. When you let people believe targets are promises rather than stretch goals. Still, it sucks to be the one to say it. That’s exactly the tension Gary Klein was trying to solve. A pre-mortem removes the pressure to keep up a ‘rah-rah, everything is great!’ mindset.</p>
<p>Instead, it creates a space <strong>where calling out risks isn’t awkward; it’s expected</strong>.</p>
<p>Since then, the concept has taken off. You’ll find pre-mortems everywhere now: in startups, corporate boardrooms, and even back in the military (a full-circle moment, really).</p>
<p>Daniel Kahneman — who many people know from his book <em>Thinking, Fast and Slow</em> — even called it his single most valuable decision-making technique. Not bad for something that started as an internal fix at a small research company.</p>
<p>Gary later expanded on the idea with what he calls the ‘double-barreled pre-mortem’. After imagining failure, you run a second round where you daydream about wild success — and what could go wrong <em>there</em>, too.</p>
<p>It sounds more optimistic, but the goal is the same: surface risks. Even success can break things. If only Ticketmaster had done that before the Taylor Swift Eras Tour ticket sales…</p>
<p>So that’s how, and why, pre-mortems started. But beyond making it easier to speak up, why else is this tactic so useful?</p>
<h2>Why do pre-mortems actually help?</h2>
<p>There are five reasons why pre-mortems are so powerful:</p>
<h3>1. They make you a better fortune teller</h3>
<p>Pre-mortems use a technique known as <em>prospective hindsight</em> (a fancy way of saying imagining the future), which Deborah Mitchell at Wharton found can <a href="https://onlinelibrary.wiley.com/doi/abs/10.1002/bdm.3960020103#:~:text=Abstract,nature%20of%20explanations%20for%20events.">increase your ability to correctly identify reasons for future outcomes by 30%</a>. All that by pretending it has happened. You basically become a better fortune teller, pretty cool, right?</p>
<p>So it helps you surface real potential risks more effectively than just thinking about them without a specific prompt.</p>
<h3>2. It creates psychological safety to talk about risks</h3>
<p>We’ve talked a bit about this already. By making the goal ‘identify risks’, you make it safe to say them out loud. People are often all thinking “the founder keeps changing priorities” or “pretty sure this plan was ChatGPT and hasn’t been checked”, but they might be too scared to say it out loud.</p>
<p>As you see others be more open, you’ll also end up being more open.</p>
<p>I think this is especially valuable when you have a room full of young, smart people. I remember at my first job, I assumed whatever senior leadership said was the right call. It felt intimidating to challenge someone with 20 years more experience than me.</p>
<p>Now, as a leader, I know you can tell people to speak up — but the reality is, not everyone will. A pre-mortem helps level that playing field. It gives people permission, and a clear prompt, to question assumptions, raise concerns, and say the thing they might otherwise keep to themselves.</p>
<h3>3. It makes the fear concrete</h3>
<p>Vague anxiety is paralyzing; we’ve all catastrophized over endless, nameless what-ifs. Specific risks, though, are manageable.</p>
<p>Once you’ve written “what if no one buys it?” on paper and mapped out a plan for it, it stops being a powerful fear. It becomes just another scenario to handle.</p>
<p>Even better, involving the whole team in a pre-mortem helps counteract the planning fallacy — that tendency to be overly optimistic, assume everything will work out, and underestimate time, costs, and risks. By making the risks concrete, you can create a clear action plan.</p>
<p>Think of it as a bit of growth group therapy.</p>
<h3>4. It removes panic from decision-making</h3>
<p>Have you ever stood in line for coffee, and suddenly it’s your turn way faster than you expected? The barista asks, “What would you like?” and you stammer out, “Um… errr, a vanilla matcha latte, wait, an iced vanilla matcha latte!” Only to take a sip and think, “What was I thinking?”</p>
<p>Okay, that might have just been my own therapy moment the other day, but the point stands: under pressure, we’re terrible at making decisions on the spot.</p>
<p>The same goes for post-launch problems — you’re stressed, stakeholders are asking questions; you’re tempted to make snap, reactive decisions. You blurt out the first thing that comes to mind, and hope for the best.</p>
<p>A pre-mortem changes that. If you’ve already decided, ‘If X happens, we do Y’, then you don’t have to think under pressure. You execute the plan. You’ve already discussed the right course of action as a team, ahead of time. I do this for every experiment I run because it makes ‘failed’ experiments easier to handle: we know what failure looks like, we can learn from it, and we already have a sense of what to test next.</p>
<h3>5. It gives you permission to take action imperfectly</h3>
<p>I once worked with a Head of Product who was basically a pre-mortem specialist, though he probably didn’t realize it, since we’d never talked about pre-mortems. He had an uncanny ability to spot every possible risk, think it through, and tackle it. Sometimes, it felt frustrating, as it could slow projects down. Until I realized that if I named the risk and laid out a plan, he was more than happy to roll with imperfection.</p>
<p>The extreme opposite of optimism in project planning isn’t pessimism, it’s <em>perfectionism</em>. Try to control everything, and you’ll never go live.</p>
<p>A pre-mortem shows you that even if things go wrong, you have a path forward. That makes it easier to ship. The goal isn’t to prevent every risk, it’s to be conscious of which risks you’re willing to accept and which you aren’t.</p>
<p>By now, hopefully, you’re convinced and starting to wonder how to run your own pre-mortem.</p>
<h2>How to run a pre-mortem</h2>
<p>While the concept itself is simple, a few small tweaks in setup and approach can make your pre-mortem far more powerful.</p>
<h3>Step 1: Gather the relevant individuals</h3>
<p>While you can do a pre-mortem alone, I can’t recommend doing it with your team enough. Different people spot different risks based on their expertise, and hearing others’ perspectives can reveal risks you might never have considered.</p>
<p>Even better: bring in someone outside the project; someone who won’t have rose-tinted glasses. Ideally, a classic naysayer who can look critically at what’s being assessed (kind of like that Head of Product I once worked with).</p>
<h3>Step 2: Set the scene</h3>
<p>Next, set the scene.</p>
<p>Say something like: “It’s [date 2–3 months from now]. This launch has failed. Not a minor disappointment, but a proper flop. What happened?”</p>
<p>Pick a timeframe that feels realistic for the project. Then lean into the drama — think back to high school theater class and really bring it to life. The goal is to make it feel real, to transport yourself to that future moment, and look back with hindsight.</p>
<p>You can even get creative: have AI mock up a founder email, a critical internal memo, or an article in a relevant newspaper or magazine. The more tangible and vivid the scenario, the easier it is to uncover the real risks.</p>
<h3>Step 3: List every reason it might have failed</h3>
<p>Have everyone list reasons <em>separately</em>. Write down everything, even if they seem unlikely. For example:</p>
<ul>
<li>We couldn’t reach enough of our target audience</li>
<li>The <a href="https://www.revenuecat.com/blog/growth/app-trial-conversion-rate-insights/">trial-to-paid conversion rate</a> is too low</li>
<li>We saw very <a href="https://www.revenuecat.com/blog/growth/subscription-app-churn-reasons-how-to-fix/">high initial churn</a></li>
<li>We struggled to get downloads</li>
<li>The price felt wrong (too high, too low, confusing)</li>
<li>A competitor launched something similar at the same time</li>
<li>We got bad press or reviews early on</li>
<li>The team burned out before we could iterate</li>
</ul>
<p>The more specific, the better. We aren’t aiming for perfection here; <em>quantity over quality</em> is the key at this stage. The goal is to surface as many potential risks as possible.</p>
<h3>Step 4: Go through the risks and group them by theme</h3>
<p>There will inevitably be some overlap in the concerns people raise. That’s okay, this is a no-judgment moment. At this stage, you’re not prioritizing; you’re organizing.</p>
<p>Give everyone a chance to voice their concerns. Whatever you do, <em>don’t</em> try to diminish them or explain why something won’t happen, especially if you’re the leader or founder.</p>
<p>Instead, focus on grouping the concerns by theme. This helps you see more clearly:</p>
<ul>
<li>Which concerns are the most common</li>
<li>Which areas seem to carry the most potential risks</li>
</ul>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/b1fa5a5d7237f850cbcb82f947815db90200d54a-715x555.png" alt=""/></figure>
<h4>Optional additional step: When pre-mortems require a culture change</h4>
<p>In some companies, getting into the pre-mortem mindset can be tricky. When things are busy or deadlines are tight, it’s easy to skip it. Or sometimes, people just feel uncomfortable bringing up the uncomfortable.</p>
<p>Here, the framework <a href="https://coda.io/@shreyas/pre-mortems">Shreyas Doshi suggests</a> can be really useful. He recommends categorizing risks into three types:</p>
<ol>
<li><strong>Tiger: </strong>a real threat — something that will hurt the company if left unaddressed.</li>
<li><strong>Paper tiger: </strong>a perceived risk/threat that, on closer inspection, is unlikely to cause real problems. These are the things the team might be stressing over, but are actually minor and manageable.</li>
<li><strong>Elephant: </strong>unsurprisingly, this is ‘the elephant in the room’ — a risk that is present but isn’t being talked about.</li>
</ol>
<p>Using this playful, visual language helps build the psychological safety that’s so important for effective pre-mortems. It makes discussions lighter and more approachable, and it gives you a way to raise risks in other settings too. For example: “This might be a paper tiger, but have we considered…?”</p>
<h3>Step 5: For each risk theme, ask two questions</h3>
<p>Now that you’ve done some idea spring cleaning, it’s time to assess risk levels and impact:</p>
<ol>
<li><strong>Certainty assessment: </strong>how likely is it to happen?</li>
<li><strong>Impact assessment: </strong>how bad is it if it happens?</li>
</ol>
<p>This helps you map everything onto a simple 2×2 grid, so you don’t get stuck debating unlikely scenarios or low-impact risks. Instead, you can focus your energy on the ones that actually matter.</p>
<p>Depending on the size of the group and the scope of the project, this might even be a separate session. The goal here isn’t to solve everything yet; it’s to start identifying which risks deserve your attention first.</p>
<h3>Step 6: Determine whether you need to plan or prevent those risks</h3>
<p>Next, focus on those biggest risks and decide whether they’re preventable, plannable, or both:</p>
<ol>
<li><strong>Prevent: </strong>can we reduce the likelihood of this happening?</li>
<li><strong>Plan: </strong>if it happens anyway, what will we do?</li>
</ol>
<p>If you try to prevent every single risk, nothing will ever go live, so this is where you need to be critical. Ask yourself: Is this something we can realistically mitigate, or is it a risk we need to consciously accept?</p>
<p>The goal isn’t to eliminate all risk. It’s to understand it and feel prepared for what might happen.</p>
<h3>Step 7: Work out potential solutions</h3>
<p>Now, depending on the complexity of the risk, this might require an extra session — or a few — to properly think through. Don’t get caught trying to solve everything on the spot. Some risks need deeper brainstorming or input from others.</p>
<p>What <em>does</em> help is summarizing your plans into simple if/then statements. They’re clear, practical, and bring a sense of calm to the unknown.</p>
<p>For example:</p>
<ul>
<li>If activation is below 10%, we’ll simplify the first-time experience and retest</li>
<li>If we attract the wrong customers, we’ll adjust our targeting and messaging before scaling spend</li>
<li>If nobody hits the paywall, we’ll move it earlier and test different copy</li>
<li>If day 1 churn is above X%, we’ll investigate whether the paywall is creating impulse conversions</li>
</ul>
<p>Each of these should be tied to a specific metric you’re already tracking — trial conversion rate, <a href="https://www.revenuecat.com/blog/growth/activation-metrics/">activation rate</a>, churn, and MRR. Set your <a href="https://www.revenuecat.com/blog/growth/subscription-metrics-mobile-apps/">trigger thresholds </a><em><a href="https://www.revenuecat.com/blog/growth/subscription-metrics-mobile-apps/">before</a></em><a href="https://www.revenuecat.com/blog/growth/subscription-metrics-mobile-apps/"> launch</a>, so you’re not debating what ‘bad’ looks like after the fact.</p>
<p>If you’re running paywall or pricing experiments, <a href="https://www.revenuecat.com/feature/experiments/">RevenueCat’s Experiments feature</a> lets you set up A/B tests and define success metrics before you launch — so you’re not making decisions under pressure.</p>
<h3>Step 8: Review and revisit</h3>
<p>One last thing: don’t let your pre-mortem gather dust in a shared doc like so many other strategy decks. Depending on your timelines, set a few clear check-in points, for example, Day 7, Day 30, and Day 90 post-launch.</p>
<p>At each one, revisit your if/then statements and ask: are any of these scenarios actually playing out?</p>
<p>This is where the pre-mortem really earns its keep. The planning is valuable, but the follow-through is what helps you avoid those “we should have seen that coming” moments later on.</p>
<p>For longer projects, it can even be worth running a second pre-mortem closer to launch; a fresh look, with more context, often surfaces new risks you couldn’t see earlier.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/a7c40351f3546c57ca11948a111ca6555c6d7acb-1448x1662.png" alt=""/></figure>
<h2>Example: subscription app pre-mortem</h2>
<p>Before launching a new subscription tier for a client last year, we ran a quick pre-mortem.</p>
<p>One of the risks we identified: ‘Existing customers might feel cheated if new customers get a better deal’.</p>
<p>We couldn’t fully prevent that. The new tier genuinely offered better value, and it needed to. It unlocked a segment of customers we were previously pricing out. But by naming the risk, we could prepare for it:</p>
<ul>
<li>We spent more time positioning the existing tier, not just promoting the new one</li>
<li>We added a relevant feature to the higher tier to reduce cannibalization</li>
<li>We communicated proactively with existing customers, explaining the new tier and who it was for</li>
<li>We prepared responses for the support team</li>
</ul>
<p>The result? Far fewer complaints than expected, and only a small increase in support tickets (mostly from customers who missed the updates).</p>
<p>More importantly, the new tier drove growth rather than cannibalizing the existing one, with overall MRR increasing significantly.</p>
<h2>When to use pre-mortems</h2>
<p>Pre-mortems work in a wide range of scenarios:</p>
<ul>
<li>Launching a new product or SKU</li>
<li>Changes to subscription models</li>
<li>Pricing adjustments</li>
<li>Entering a new market or channel</li>
<li>Rebrands or major website overhauls</li>
<li>Paid campaign pushes</li>
</ul>
<p>I even run them with new advisory clients to make sure I can support them effectively and drive real impact.</p>
<p>Basically, try a pre-mortem anytime failure would hurt, and you want to have thought it through in advance.</p>
<h2>A pre-mortem won’t make launches less scary</h2>
<p>You’ll probably still refresh dashboards too often and overanalyze early data — I certainly do.</p>
<p>But the <em>quality</em> of your anxiety changes. Instead of a vague dread that something <em>might</em> go wrong, you have a clear list of what <em>could</em> go wrong, and a plan for each scenario.</p>
<p>The best launches I’ve been part of weren’t the ones where nothing went wrong. They were the ones where we’d already talked through what we’d do if things did go wrong.</p>
<p>So before your next <a href="https://www.revenuecat.com/blog/growth/paywall-tests-grow-app-revenue/">paywall test</a>, pricing change, or new tier launch, take 30 minutes with your team. Imagine the worst, write it down, make a plan.</p>
<p>Then ship it anyway.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[A complete guide to migrating from Google Play Billing v7 to v8 (and preparing for v9)]]></title>
      <link>https://www.revenuecat.com/blog/engineering/play-billing-8-migration</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/play-billing-8-migration</guid>
      <pubDate>Fri, 08 May 2026 00:41:42 GMT</pubDate>
      <dc:creator><![CDATA[Jaewoong Eum]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[This article covers the Google Play Billing Library v7 to v8 migration timeline, removed APIs and replacements, updated connection/query/purchase flows, new v8–v8.3 behaviors, and how to prepare for v9.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/37af14ab5a915ef2359e583c5208f7f9a39e7d22-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>Every Android subscription team lives on the same two year clock. The Play Billing Library you shipped with last year is on a deprecation timer, and the Play Console will refuse new uploads the moment the timer runs out. v7 is the next version to hit that wall. After August 31, 2026, you can no longer publish a new app or update built against v7, and v8 has been generally available since June 30, 2025 with significant additions still arriving in v8.1 through v8.3. This guide walks you through the migration end to end, then sets you up for v9.</p>
<p>In this article, you’ll explore the v7 to v8 deprecation timeline and what each date actually means, the full list of removed APIs and their replacements, a step by step migration of the connection, query, and purchase flows, the new behaviors v8 adds that are worth adopting today, what v8.1 through v8.3 layer on top, and how to prepare for v9 before Google publishes a single line of its API surface.</p>
<h2><strong>The deprecation timeline that sets your deadline</strong></h2>
<p>Google has run a two year deprecation cycle for the Play Billing Library across several major versions. Every major version gets two years of support after release, then the Play Console stops accepting new builds compiled against it. The deprecation FAQ publishes the exact dates:</p>
<table>
<thead><tr>
<th><p>Library version</p></th>
<th><p>Last date for new apps and updates</p></th>
<th><p>Extension request deadline</p></th>
</tr></thead>
<tbody>
<tr>
<td><p>5.x</p></td>
<td><p>2024-08-31</p></td>
<td><p>2024-11-01</p></td>
</tr>
<tr>
<td><p>6.x</p></td>
<td><p>2025-08-31</p></td>
<td><p>2025-11-01</p></td>
</tr>
<tr>
<td><p><strong>7.x</strong></p></td>
<td><p><strong>2026-08-31</strong></p></td>
<td><p><strong>2026-11-01</strong></p></td>
</tr>
<tr>
<td><p>8.x</p></td>
<td><p>2027-08-31</p></td>
<td><p>2027-11-01</p></td>
</tr>
</tbody>
</table>
<p>The phrasing in the FAQ is precise and worth reading carefully: “This deprecation prevents only new apps and updates from using older versions of the Play Billing Library. Existing apps that use a deprecated version of the library will continue to function as expected.”</p>
<p>That distinction matters. The deadline is a publishing gate, not a runtime kill switch. Already published v7 binaries keep transacting after August 31, 2026. What stops is your ability to ship a new release. If your app receives any updates at all, including security patches and bug fixes, you need to be on v8 before that gate closes. The extension request deadline of November 1, 2026 is for teams that need to file a formal request through the Play Console to keep shipping v7 builds for a brief additional window. It is not a free extension you receive automatically.</p>
<p>The Play Console signals this through a warning and an inbox message on the Policy status page once your app is on a deprecated version. If your <code>AndroidManifest.xml</code> does not contain the entry named <code>com.google.android.play.billingclient.version</code>, the Play Console cannot detect your library version, and the warning never reaches you. That entry is added automatically by the library, but manifest merging in multi module projects sometimes drops it. Verify it exists in your final merged manifest before you trust the absence of warnings.</p>
<h2><strong>What changed in v8: the removals at a glance</strong></h2>
<p>v8 removed several APIs that had been deprecated for one or two prior versions. If your code still uses any of these, the v8 upgrade will fail to compile until you replace them.</p>
<table>
<thead><tr>
<th><p>Removed API</p></th>
<th><p>Replacement</p></th>
</tr></thead>
<tbody>
<tr>
<td><p><code>querySkuDetailsAsync()</code></p></td>
<td><p><code>queryProductDetailsAsync(QueryProductDetailsParams, ProductDetailsResponseListener)</code></p></td>
</tr>
<tr>
<td><p><code>queryPurchaseHistoryAsync()</code> (all overloads)</p></td>
<td><p>No direct client API. Track history server side.</p></td>
</tr>
<tr>
<td><p><code>enablePendingPurchases()</code> (no arg)</p></td>
<td><p><code>enablePendingPurchases(PendingPurchasesParams)</code></p></td>
</tr>
<tr>
<td><p><code>queryPurchasesAsync(String skuType, PurchasesResponseListener)</code></p></td>
<td><p><code>queryPurchasesAsync(QueryPurchasesParams, PurchasesResponseListener)</code></p></td>
</tr>
<tr>
<td><p><code>BillingClient.Builder.enableAlternativeBilling()</code></p></td>
<td><p><code>enableUserChoiceBilling(UserChoiceBillingListener)</code></p></td>
</tr>
<tr>
<td><p><code>AlternativeBillingListener</code></p></td>
<td><p><code>UserChoiceBillingListener</code></p></td>
</tr>
<tr>
<td><p><code>AlternativeChoiceDetails</code></p></td>
<td><p><code>UserChoiceDetails</code></p></td>
</tr>
</tbody>
</table>
<p>The signature of <code>ProductDetailsResponseListener.onProductDetailsResponse()</code> also changed. The callback now returns a <code>QueryProductDetailsResult</code> that splits the response into a fetched list and an unfetched list with per product status codes, replacing the older single list signature. Any class that implements this listener has to be updated, even if the rest of its logic is unchanged.</p>
<p>Two terminology changes accompany the API removals. “In app items” are now called “one time products” throughout the documentation and the API surface. The class names themselves did not change, the documentation simply uses the new term, but the rename carries a behavioral implication: one time products in v8 can have multiple purchase options and offers, the same way subscriptions did in v7.</p>
<h2><strong>Step 1: Bump the dependency and your minSdk</strong></h2>
<p>The first change is the Gradle coordinate. v8.0.0 was the first release in the v8 line, but most teams should target the latest patch in the v8 series instead of pinning to 8.0.0:</p>
<pre><code class="language-kotlin">dependencies {
    val billingVersion = &quot;8.3.0&quot;
    implementation(&quot;com.android.billingclient:billing:$billingVersion&quot;)
    implementation(&quot;com.android.billingclient:billing-ktx:$billingVersion&quot;)
}</code></pre>
<p>The library raised its <code>minSdkVersion</code> to 23 starting at v8.1.0. If your app still supports API 21 or 22, you have two choices: pin to 8.0.0, which keeps the original v7 minSdk of 21, or raise your own minSdk to 23 and adopt the latest v8 patch. The latter is the right call for almost every team, since API 23 was released in 2015 and the device tail below it has been negligible for years. v8.1 is also built against Kotlin 2.2.0, which can affect projects on older Kotlin versions if you consume the <code>billing-ktx</code> artifact, though this is a small jump for any project already on a recent Kotlin Compose stack.</p>
<h2><strong>Step 2: Migrate enablePendingPurchases</strong></h2>
<p>The no argument <code>enablePendingPurchases()</code> was deprecated in v7 and removed in v8. It used to be a single method that turned on pending purchase support for in app items implicitly. v8 replaces it with a builder that forces you to declare which product categories you want pending support for.</p>
<p>In v7, you had:</p>
<pre><code class="language-kotlin">val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases()
    .build()</code></pre>
<p>In v8, the equivalent call is:</p>
<pre><code class="language-kotlin">val pendingPurchasesParams = PendingPurchasesParams.newBuilder()
    .enableOneTimeProducts()
    .build()

val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases(pendingPurchasesParams)
    .build()</code></pre>
<p>Notice that <code>.enableOneTimeProducts()</code> is not implicit. The deprecated zero argument version was, by Google’s own description, equivalent to a builder with <code>.enableOneTimeProducts()</code> set, so if you do not include this call you will silently lose pending purchase support for one time products. That regression is invisible in unit tests and in most countries with credit card based purchase flows. It surfaces as broken purchases in markets where users pay with cash at convenience stores or with bank transfers, primarily Japan, Germany, Brazil, Mexico, and Indonesia. Add <code>.enableOneTimeProducts()</code> even if you think you do not need it.</p>
<p>If you sell prepaid subscription top ups, also add <code>.enablePrepaidPlans()</code> to the builder. This was added in v7 specifically for prepaid plan pending transactions and carries over into v8 unchanged.</p>
<h2><strong>Step 3: Update queryProductDetailsAsync to the new callback shape</strong></h2>
<p><code>queryProductDetailsAsync</code> itself is not removed, but its callback was. The pre v8 listener received a <code>BillingResult</code> and a <code>List&lt;ProductDetails&gt;</code>. The v8 listener receives a <code>BillingResult</code> and a <code>QueryProductDetailsResult</code> that splits products into two lists. Any product that failed to fetch shows up in the unfetched list with its own status code, instead of being silently dropped.</p>
<p>The pre v8 callback looked like this:</p>
<pre><code class="language-kotlin">billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -&gt;
    if (billingResult.responseCode == BillingResponseCode.OK) {
        productDetailsList.forEach { details -&gt;
            renderProduct(details)
        }
    }
}</code></pre>
<p>The v8 version exposes both the successful fetches and the failures:</p>
<pre><code class="language-kotlin">billingClient.queryProductDetailsAsync(params) { billingResult, queryProductDetailsResult -&gt;
    if (billingResult.responseCode == BillingResponseCode.OK) {
        queryProductDetailsResult.productDetailsList.forEach { details -&gt;
            renderProduct(details)
        }
        queryProductDetailsResult.unfetchedProductList.forEach { unfetched -&gt;
            logUnfetched(unfetched.productId, unfetched.statusCode)
        }
    }
}</code></pre>
<p>The reason this matters is that pre v8, if you queried five products and one of them was misconfigured in the Play Console, the whole call could return successfully with that product silently missing from the list. Your paywall would render four products and you would have no idea the fifth was even attempted. The new shape makes the partial failure explicit. Each unfetched entry carries a status code that tells you whether the product is unavailable in the user’s country, has not yet been published, or hit a transient error.</p>
<p>This is also the moment to rebuild any abstraction layer that wraps <code>queryProductDetailsAsync</code> in a coroutine or flow. The return type of your suspending wrapper has to change from <code>List&lt;ProductDetails&gt;</code> to a sealed class or pair that carries the unfetched information forward. If you have a typealias for the listener type itself, the typealias has to be updated, since the lambda signature is different.</p>
<h2><strong>Step 4: Replace the deprecated queryPurchasesAsync overload</strong></h2>
<p>v8 removed the overload of <code>queryPurchasesAsync</code> that took a raw SKU type string. The replacement uses a <code>QueryPurchasesParams</code> builder that wraps the same product type with the new typed enum.</p>
<p>Before:</p>
<pre><code class="language-kotlin">billingClient.queryPurchasesAsync(BillingClient.SkuType.SUBS) { result, purchases -&gt;
    handlePurchases(result, purchases)
}</code></pre>
<p>After:</p>
<pre><code class="language-kotlin">val params = QueryPurchasesParams.newBuilder()
    .setProductType(BillingClient.ProductType.SUBS)
    .build()

billingClient.queryPurchasesAsync(params) { result, purchases -&gt;
    handlePurchases(result, purchases)
}</code></pre>
<p>The behavior is identical when invoked correctly. The <code>SkuType</code> constants are gone, replaced by <code>ProductType.SUBS</code> and <code>ProductType.INAPP</code>. If you have a single shared method that accepts a string for the SKU type, refactor it to accept a <code>String</code> that matches the new enum values, or convert it to use <code>BillingClient.ProductType</code> directly.</p>
<h2><strong>Step 5: queryPurchaseHistoryAsync has no client side replacement</strong></h2>
<p><code>queryPurchaseHistoryAsync</code> was deprecated in v7 and fully removed in v8. There is no equivalent client API. This is the only v8 removal that does not have a direct one to one replacement, and it tends to surprise teams the most.</p>
<p>Google’s reasoning is straightforward, even if undocumented in the migration page itself: purchase history is not authoritative source of truth on the client. Any history a client builds up can be incomplete, since the device may have been offline during a refund or revocation. The Play Developer API on your server has the authoritative record, and any analytics, audit, or reconciliation flow that depends on knowing past purchases should query it server side instead.</p>
<p>If you previously used <code>queryPurchaseHistoryAsync</code> for one of these reasons, you now have two paths. The first is to call the Play Developer API endpoint <code>purchases.subscriptionsv2.get</code> from your backend whenever you need to inspect a subscription’s full history, including its <code>linkedPurchaseToken</code> chain. The second is to lean on Real Time Developer Notifications and persist the events as they arrive, building your own purchase history table. Most teams already do the latter for analytics and revenue reporting, in which case the v8 removal is a no op.</p>
<p>If you used <code>queryPurchaseHistoryAsync</code> for entitlement restoration on a fresh install or after a reinstall, switch to <code>queryPurchasesAsync</code> with the new params builder. <code>queryPurchasesAsync</code> returns active purchases tied to the current Google account, which is what restoration actually needs. Active purchases is a smaller set than full history, but it is the correct set for entitlement granting. Note that v8 also stops returning consumed one time products and expired subscriptions from <code>queryPurchasesAsync</code>, so configure your one time products as non consumable in the Play Console if you need them to survive a reinstall.</p>
<h2><strong>Step 6: Move from alternative billing to user choice billing</strong></h2>
<p>v7 already deprecated the <code>enableAlternativeBilling</code> builder method, and v8 removed it entirely. The replacement is <code>enableUserChoiceBilling</code>, which takes a <code>UserChoiceBillingListener</code> instead of an <code>AlternativeBillingListener</code> and exposes a <code>UserChoiceDetails</code> payload instead of <code>AlternativeChoiceDetails</code>.</p>
<pre><code class="language-kotlin">val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enableUserChoiceBilling { userChoiceDetails -&gt;
        recordExternalSelection(userChoiceDetails)
    }
    .enablePendingPurchases(pendingPurchasesParams)
    .build()</code></pre>
<p>The functional contract is the same: when the user picks your billing system over Google Play’s at checkout, the listener fires with the purchase details so your backend can complete the transaction outside of Google Play’s payment flow. Only the type names changed. If you carry a per region build flavor where user choice billing is enabled in some regions and disabled in others, the build flavor logic does not need to change, only the type references.</p>
<h2><strong>One time products and the multi offer surface</strong></h2>
<p>v8 renamed in app items to one time products and gave them the same multi offer capability subscriptions had since v5. A single one time product can now carry multiple purchase options, each with its own price, regional availability, and offers attached. This is the change that motivates the bulk of v8’s surface, and it is also the reason the unfetched product list now exists. With multiple offers per product, the partial failure modes multiplied, and a flat list could no longer represent what happened.</p>
<p>The class names did not change. <code>ProductDetails</code> is still <code>ProductDetails</code>. What changed is the <code>oneTimePurchaseOfferDetails</code> accessor, which can now expose multiple offers and pre-order details starting at v8.1.0. If your paywall renders a single price for an in app item, you are still on the v7 mental model. v8 lets you expose a discounted offer or a regional offer on the same product without creating a duplicate SKU. This is opt in: existing one time products continue to surface a single offer until you configure additional options in the Play Console.</p>
<h2><strong>New behaviors worth adopting in v8</strong></h2>
<p>Two of v8’s additions are not strictly required but are worth turning on at migration time, since they reduce code you would otherwise carry yourself.</p>
<p>The first is <code>enableAutoServiceReconnection()</code>. The Play Billing service can disconnect for a number of reasons: the user installs an update to Google Play, the device wakes from a long sleep, the system reclaims the bound service. Pre v8, you had to implement <code>onBillingServiceDisconnected()</code> and call <code>startConnection()</code> again with appropriate backoff. v8 ships an opt in builder method that handles this for you:</p>
<pre><code class="language-kotlin">val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases(pendingPurchasesParams)
    .enableAutoServiceReconnection()
    .build()</code></pre>
<p>When auto reconnection is enabled, your <code>onBillingServiceDisconnected()</code> override can be a no op, and the library handles retry with backoff internally. Auto reconnection is opt in rather than the default. If you want to manage connection lifecycle yourself, leave it disabled and continue handling <code>onBillingServiceDisconnected()</code> directly.</p>
<p>The second addition is <code>BillingResult.subResponseCode</code>. Pre v8, the <code>BillingResult</code> returned for purchase failures grouped many cases under <code>BILLING_UNAVAILABLE</code> or <code>ERROR</code>. v8 adds a sub response code on <code>BillingResult</code> that distinguishes specific failure cases:</p>
<ul>
<li><code>PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS</code>: the user’s payment method does not have sufficient funds.</li>
<li><code>USER_INELIGIBLE</code>: the user is not eligible for the offer they tried to redeem, typically because they have already used a one time intro offer.</li>
<li><code>NO_APPLICABLE_SUB_RESPONSE_CODE</code>: the failure does not map to a more specific sub code.</li>
</ul>
<p>The ineligibility code is the one you almost certainly want to handle, because it lets you steer the user back to a fallback offer instead of leaving them at a failed purchase screen. The sub code arrives in the <code>BillingResult</code> delivered to your <code>PurchasesUpdatedListener.onPurchasesUpdated</code> callback:</p>
<pre><code class="language-kotlin">override fun onPurchasesUpdated(result: BillingResult, purchases: List&lt;Purchase&gt;?) {
    if (result.responseCode == BillingResponseCode.ITEM_UNAVAILABLE) {
        when (result.subResponseCode) {
            SubResponseCode.USER_INELIGIBLE -&gt; showFallbackOffer()
            SubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS -&gt; showFundsError()
            else -&gt; showGenericError()
        }
    }
}</code></pre>
<h2><strong>What v8.1, v8.2, and v8.3 add on top</strong></h2>
<p>v8 has been moving fast since GA. Three minor releases between November and December 2025 added significant capabilities that are easy to miss if you only read the v8.0 migration guide.</p>
<p>v8.1.0, released November 6, 2025, introduced suspended subscription handling. A subscription enters the suspended state when Google detects abuse or a payment dispute and pauses delivery of entitlements without canceling the subscription. Pre v8.1, suspended subscriptions were invisible to client side queries. v8.1 adds an <code>isSuspended</code> flag on <code>Purchase</code> and a parameter to <code>queryPurchasesAsync</code> to include suspended purchases in the result. The pattern is to filter purchases by <code>isSuspended</code> before granting entitlement, and route suspended ones into a separate UI branch that explains the hold to the user:</p>
<pre><code class="language-kotlin">val params = QueryPurchasesParams.newBuilder()
    .setProductType(BillingClient.ProductType.SUBS)
    .build()

billingClient.queryPurchasesAsync(params) { result, purchases -&gt;
    purchases.forEach { purchase -&gt;
        if (purchase.isSuspended) {
            showSuspendedState()
        } else if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
            grantEntitlement(purchase)
        }
    }
}</code></pre>
<p>v8.1 also reworks subscription replacement. The old <code>SubscriptionUpdateParams.setSubscriptionReplacementMode()</code> is now deprecated in favor of <code>SubscriptionProductReplacementParams</code> set on the per product builder. The new shape supports a <code>KEEP_EXISTING</code> replacement mode that lets you upsell a second subscription on the same account without canceling the existing one, which is useful for bundled offerings.</p>
<p>v8.2.0 and v8.2.1, released in December 2025, generalize what used to be the External Offers Program into a broader Billing Program surface. The old methods (<code>enableExternalOffer</code>, <code>isExternalOfferAvailableAsync</code>, <code>createExternalOfferReportingDetailsAsync</code>, <code>showExternalOfferInformationDialog</code>) are deprecated and replaced by <code>enableBillingProgram(int)</code>, <code>isBillingProgramAvailableAsync</code>, <code>createBillingProgramReportingDetailsAsync</code>, and <code>launchExternalLink</code>. v8.3.0, shipped December 23, 2025, adds the developer provided billing flow on top, with <code>BillingProgram.EXTERNAL_PAYMENTS</code>, <code>EnableBillingProgramParams</code>, and <code>DeveloperBillingOptionParams</code>. This consolidation is Google’s response to the regulatory pressure that emerged from the Epic Games ruling and similar requirements in other jurisdictions, where Google is required to support developer provided billing and out of app payment links.</p>
<p>If your app uses the External Offers Program today, the deprecation is a clear signal to start migrating. The methods still work in v8.2 and v8.3, but they will likely be removed in v9. Migrating to the Billing Program surface now is a one time cost that buys you out of a forced migration later.</p>
<h2><strong>Preparing for v9 before its API surface exists</strong></h2>
<p>At the moment, the release notes top out at v8.3.0. The deprecation FAQ does not list a v9 row. There is no public alpha, beta, or release candidate. So how do you prepare?</p>
<p>By understanding the cadence and the direction of change. Google has shipped a new major version of the Play Billing Library roughly once a year, with v7 released in May 2024 and v8 reaching GA on June 30, 2025. On that pattern, v9 is expected to land in mid 2027, lining up with the v8 sunset deadline of August 31, 2027.</p>
<p>The direction of change is also predictable from the rapid v8.1 through v8.3 releases. Google is consolidating the external billing surface (External Offers Program, user choice billing, developer provided billing) into a unified Billing Programs API. v9 is likely to remove the deprecated External Offers methods, formalize the Billing Programs surface, and possibly extend it further to handle additional regulatory regimes.</p>
<p>Three things you can do today to be ready:</p>
<ol>
<li><strong>Opt in early</strong>: adopt every opt in API v8 ships, including <code>enableAutoServiceReconnection()</code> and the new <code>SubscriptionProductReplacementParams</code>. The opt in surface in one major version is the default surface in the next.</li>
<li><strong>Migrate off External Offers</strong>: the Billing Programs surface in v8.2 and v8.3 is the forward path, and it gives you a non breaking migration window before v9 forces it.</li>
<li><strong>Move history server side</strong>: v8 removed <code>queryPurchaseHistoryAsync</code> for a reason, and v9 will not reintroduce it. Build your purchase audit trail server side from RTDN events and the Play Developer API.</li>
</ol>
<p>If you do these three things in your v8 migration, the v9 migration in mid 2027 becomes incremental rather than disruptive.</p>
<h2><strong>How RevenueCat absorbs the v7 to v8 transition</strong></h2>
<p>The recurring nature of Play Billing Library migrations is the kind of work that taxes a small team disproportionately. Every two years, someone has to read the migration guide, refactor the connection layer, retest pending purchases in cash payment markets, audit the <code>queryPurchaseHistoryAsync</code> removal, and verify that no listener signature change broke a downstream module. The work is rarely interesting, and it always lands on the team that least wants to do it.</p>
<p>RevenueCat absorbs this migration on your behalf. The RevenueCat Android SDK 9.x ships with Play Billing Library 8 already integrated, and the SDK API surface most apps interact with did not change between RC SDK 8.x and 9.x. The only meaningful changes for app code are a Kotlin minimum of 1.8.0 and the removal of <code>data class</code> modifiers from a handful of public types, which means <code>copy()</code> and component destructuring no longer work on those types. <code>equals()</code> and <code>hashCode()</code> are preserved. The migration path is documented in the <a href="https://www.revenuecat.com/docs/sdk-guides/android-native-8x-to-9x-migration">RevenueCat 8.x to 9.x Android migration guide</a>, and the rationale and feature set are walked through in the <a href="https://www.revenuecat.com/blog/engineering/google-play-billing-v8/">Play Billing Library 8 support in Purchases SDK v9.0.0</a> engineering post.</p>
<p>The practical implication is that the v7 to v8 migration steps in this article reduce to: bump your RevenueCat SDK from 8.x to 9.x, bump your Kotlin to 1.8 or later, replace any <code>copy()</code> or destructuring on the affected RC types with explicit constructors, and verify your one time products are configured as non consumables in the RevenueCat dashboard (since v8 cannot query consumed one time products). That is the entire migration. The v8 specific work, including the new <code>PendingPurchasesParams</code> builder, the <code>queryProductDetailsAsync</code> callback shape, the <code>queryPurchasesAsync</code> overload swap, the user choice billing rename, and the unfetched product handling, is already done inside the SDK.</p>
<p>The same property holds for v9. When v9 ships, RevenueCat will release a new SDK that integrates it, and your app code moves forward with the same pattern: bump the SDK, possibly bump your Kotlin or minSdk, and run your test suite. The recurring Play Billing Library migration stops being something your team has to schedule.</p>
<h2><strong>Conclusion</strong></h2>
<p>In this article, you’ve explored the full v7 to v8 migration: the deprecation timeline that sets your August 31, 2026 deadline, the removed APIs and their replacements, the six concrete migration steps from Gradle bump through user choice billing, the new behaviors v8 ships with <code>enableAutoServiceReconnection</code> and <code>BillingResult.subResponseCode</code>, the three minor releases that added suspended subscriptions and the unified Billing Programs surface, and a pattern based read on what v9 will likely require.</p>
<p>Understanding the structural reasons behind each removal helps you make better decisions during the migration itself. <code>queryPurchaseHistoryAsync</code> is gone because purchase history was never authoritative on the client. The new <code>enablePendingPurchases</code> builder is explicit because the implicit version silently regressed cash payment flows in some markets. The unfetched product list exists because partial failures had to become visible once one time products gained multiple offers. Each change reflects a problem the prior surface failed to handle, and the v9 changes will follow the same logic.</p>
<p>Whether you’re migrating to v8 this quarter, planning the v9 transition for 2027, or evaluating whether to outsource billing infrastructure to a platform like RevenueCat, this foundation lets you ship Android subscriptions without losing weeks every two years to a forced upgrade. For the official source material, refer to Google’s <a href="https://developer.android.com/google/play/billing/migrate-gpblv8">migrating to Play Billing Library 8</a> page, the <a href="https://developer.android.com/google/play/billing/deprecation-faq">deprecation FAQ</a>, and the <a href="https://developer.android.com/google/play/billing/release-notes">release notes</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Meet Rico: Your tireless app growth advisor]]></title>
      <link>https://www.revenuecat.com/blog/company/rico-app-growth-advisor</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/company/rico-app-growth-advisor</guid>
      <pubDate>Wed, 06 May 2026 16:35:37 GMT</pubDate>
      <dc:creator><![CDATA[Niklas Winkels]]></dc:creator>
      <category><![CDATA[Company]]></category>
      <description><![CDATA[Ask Rico what changed, what it means, and what to do next, right from RevenueCat or Slack.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/4fb6d669481a1896af9e3077fa21ae83b6d67cc8-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>You’ve got the charts. You’ve got the cohorts. You’ve got the experiment results. But somewhere between the data and your next decision, there’s a question you’re not sure how to answer.</p>
<p>Meet Rico, RevenueCat’s app growth advisor built directly into your RevenueCat dashboard and available in Slack. Ask why conversions dropped, which experiment to run, or how your churn rate compares to similar apps. Rico turns your RevenueCat data into your next move.</p>
<h3>An advisor, not a search bar</h3>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/6da48f862ca2f1ad26ebc9a2a6e46cb348bda10a-1859x1327.png" alt=""/></figure>
<p>Rico is an embedded domain expert with access to your RevenueCat data, the App Store and Google Play Billing Library documentation, and the industry-wide State of Subscription Apps (SOSA) benchmarks.</p>
<p>Not only can you ask Rico straightforward questions like “show me MRR by country” or “compare this month’s performance vs last month,” you can ask Rico real, business-outcome questions:</p>
<ul>
<li><em>“Where am I leaving money on the table in the trial-to-paid flow?”</em></li>
<li><em>“Based on the A/B test, should we do a 0 vs 7-day trial next?”</em></li>
<li><em>“What is the average conversion to paying for a fitness app?”</em></li>
</ul>
<h3>What Rico can do for you</h3>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/a705430b07f94f04de046bc952ad28f0b79aebf2-1015x860.png" alt=""/></figure>
<p>Rico’s capabilities span across data analysis, technical debugging, and growth strategy. Here is what your new advisor can do:</p>
<p><strong>Diagnose revenue and retention</strong></p>
<p><em>“Why did MRR drop in Germany last week?”</em></p>
<p>Rico can pull your overview metrics, fetch chart data with complex filters, and compare periods to spot anomalies. If revenue drops, Rico can surface exactly which cohort, country, or product is driving the change.</p>
<p><strong>Review product and entitlement setups</strong></p>
<p><em>“Is my annual product configured correctly in the App Store?”</em></p>
<p>App Store and Google Play configurations are notoriously easy to get wrong. Rico can review your products, entitlements, offerings, and packages to verify that everything is attached correctly and properly configured in the stores.</p>
<p><strong>Analyze experiments and strategy</strong></p>
<p><em>“Based on the A/B test, should we do a 0 vs 7-day trial next?”</em></p>
<p>Rico lists your A/B test results and helps you interpret the winners and losers. It can provide paywall and pricing strategy recommendations, trial optimization advice, and retention analysis.</p>
<p><strong>Benchmark against the industry</strong></p>
<p><em>“How does my app’s trial conversion compare to similar apps in my category?”</em></p>
<p>Rico has a dedicated benchmarks tool that compares your project’s actual metrics against industry peers. Ask how your trial conversion, churn rate, or MRR growth stacks up against apps in your category. Rico surfaces where you’re ahead, where you’re behind, and what to test next, using data from the SOSA reports.</p>
<p><strong>Deep-dive into customers</strong></p>
<p><em>“Look up user abc123: they say they paid but don’t have access.”</em></p>
<p>When a user writes in with a billing issue, give Rico their app user ID. Rico pulls their full profile: active entitlements, subscription status, purchase history, and any custom attributes your app has set.</p>
<p>Then Rico tells you what it sees. Is the subscription in billing retry? Did they cancel but still have access? Have they been refunded before? Are they enrolled in an experiment that might explain unexpected behavior? For most support tickets, that’s enough to diagnose the issue and respond with confidence.</p>
<p><strong>Research documentation and SDKs</strong></p>
<p><em>“How do I implement a custom paywall in React Native?”</em></p>
<p>Rico has full access to the RevenueCat docs and all open-source SDK repositories across iOS, Android, Flutter, React Native, Unity, KMP, and JS. Ask about an implementation detail and Rico finds the exact code snippet or proven approach. Ask about a recent SDK release and Rico pulls the changelog. If something seems broken, Rico can check the live status of RevenueCat’s API.</p>
<p><strong>Learn from the best in the industry</strong></p>
<p><em>“What do high-retention apps do differently in their first 30 days?”</em></p>
<p>Rico can search the RevenueCat blog and pull insights from the SubClub podcast. Hundreds of conversations with the founders and operators who have built the most successful subscription apps. Ask Rico what high-retention apps do differently, how apps in your category structure their trial strategy, or what operators have figured out about reducing involuntary churn.</p>
<p><strong>Project and team management</strong></p>
<p><em>“Who changed the annual product entitlement yesterday?”</em></p>
<p>Rico can list your apps, API keys, and collaborators, and pull your project’s audit log. Every entry records who made a change, what they changed, and when. That covers everything from product and entitlement config to experiment activity, webhook updates, and collaborator role changes.</p>
<p><strong>Feature adoption audit</strong></p>
<p><em>“Which RevenueCat features am I not using that I should be?”</em></p>
<p>Ask Rico to run a feature adoption audit on your project. It checks every RevenueCat feature against your actual usage data and returns three things: what you’re actively using, what’s gone dormant, and what you’ve never tried.</p>
<p>Rico might flag that your Refund Handling integration stopped flowing data three months ago, meaning Apple has been approving refunds without your input. Or it might notice you’re running Customer Center but haven’t enabled the Retention Messaging API, which gives you a second chance to retain users when they try to cancel in the App Store.</p>
<p><strong>App and competitor research</strong></p>
<p><em>“What’s Duolingo’s monetization strategy?”</em></p>
<p>Give Rico any app’s bundle ID or package name and it will pull the App Store and Google Play metadata side by side. Ratings, pricing, category, IAP structure, localization breadth, release cadence, the full picture.</p>
<p>Ask Rico to look up a competitor and it will flag what their monetization strategy tells you: whether they’re running a trial-led conversion model, how their content scope affects willingness to pay, what their rating trajectory suggests about product-market fit.</p>
<h3><strong>Rico in Slack</strong></h3>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/275891939bb8c657eadade967cf2710db0c7b3ed-1416x1284.png" alt=""/></figure>
<p>Most revenue questions start in Slack. Someone posts a screenshot and asks, “Anyone know why this dropped?” Then everyone waits for the one person who knows how to pull the number.</p>
<p>With the Slack integration, you can now tag <code>@Rico</code> in any channel with a question. The answer lands in the thread: the data, the context, and what to do next. Visible to everyone in the conversation.</p>
<p>To <a href="https://www.revenuecat.com/docs/dashboard-and-metrics/dashboard-and-metrics/rico#installing-rico-in-slack">connect Rico</a> to your Slack workspace, go to Account Settings in the RevenueCat dashboard, open the Rico tab, and click Connect to authorize the integration.</p>
<h3><strong>Try Rico today</strong></h3>
<p>Rico is available in public beta.</p>
<p>Head to your RevenueCat dashboard and ask for a health check. Tell Rico to run a feature adoption audit, or ask: <em>“What’s the biggest bottleneck to my revenue growth right now?”</em></p>
<p>Open the dashboard or add Rico to Slack. Ask your first question.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Build a paywall by describing what you want]]></title>
      <link>https://www.revenuecat.com/blog/company/paywalls-ai-editor</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/company/paywalls-ai-editor</guid>
      <pubDate>Wed, 06 May 2026 13:49:08 GMT</pubDate>
      <dc:creator><![CDATA[Niklas Winkels]]></dc:creator>
      <category><![CDATA[Company]]></category>
      <description><![CDATA[Build and refine production-ready paywalls in seconds with RevenueCat’s Paywalls AI Editor.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/7d5b445500e73e9fb5f71c67083b915c8a68e781-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>At the <a href="https://www.youtube.com/watch?v=bcnUYWLo-I4">RevenueCat World Paywall Speed Building Championships 2025</a>, the winner built a complete, production-ready paywall in 3 minutes and 16 seconds. It was an incredible feat of human engineering and drag-and-drop mastery.</p>
<p>You can now make that record completely obsolete.</p>
<p>You know you need to test your paywalls. The difference between a mediocre paywall and a great one is often the difference between a struggling app and a thriving subscription business.</p>
<p>But for anyone who isn’t a speed-building champion, the reality is different. Creating a paywall that looks good, handles every edge case, and is ready to publish requires design skills and engineering time you don’t have. Iterating on it takes even longer.</p>
<p>The result is a “good enough” paywall that never gets tested.</p>
<p>RevenueCat’s Paywalls AI Editor changes this. It’s a conversational paywall agent that lets you generate a production-ready paywall from a text prompt in seconds.</p>
<h2>From drag-and-drop to done-in-seconds</h2>
<p>You can edit any aspect of your paywall using natural language. Type “make the annual plan more prominent” or “use a darker color palette.” Every change streams live in the preview.</p>
<p>RevenueCat Paywalls AI Editor handles the entire paywall creation process from end to end. Whether you’re starting from a blank canvas or adapting an existing template, the AI Editor gives you complete control over every layer of your paywall.</p>
<h3>Copywriting and conversion</h3>
<p>Great paywalls live and die on their copy. You can ask the builder to rewrite your headlines, subheads, and CTA text to match your brand voice. Personalize the copy using custom variables like {{ custom.first_name }} or rewrite pricing language with variables like {{ product.price }} and {{ product.relative_discount }}. Clean up awkward copy, fix mismatched monthly and yearly labels, and rebalance plan emphasis, for example, changing the default selected plan, updating badge wording, or anchoring users toward the annual option.</p>
<h3><strong>Visual design and layout</strong></h3>
<p>You control the entire visual mood through natural language. Ask the builder to restyle your colors, adjust spacing, update typography, or fix contrast issues. Add or refine your layout for light and dark modes separately, for example, a white background with dark text in light mode and a deep navy with a glowing CTA in dark mode. Generate and swap imagery for your hero sections and backgrounds to better match your brand on the fly.</p>
<h3><strong>Components and structure</strong></h3>
<p>Building a paywall means assembling the right pieces. The AI Editor drops in whatever you need on command. You can add headers, feature lists, testimonials, comparison tables, timelines, carousels, and package groups. Remove, duplicate, reorder, or move any existing section, for example, pulling a testimonial block above the pricing cards to build trust before the ask. Ensure compliance by adding sticky footers and updating your Terms and Privacy URLs.</p>
<h3><strong>Localization</strong></h3>
<p>Scaling your app means translating your paywall. The AI Editor adds localizations directly to your layout and updates translated copy across every component. Fine-tune individual localized strings, for example, shortening a German CTA that overflows its button and test how different languages affect your layout before you push the changes live.</p>
<h3><strong>Pre-launch auditing</strong></h3>
<p>Before you ship, the builder acts as a second set of eyes. Ask it to audit your paywall for common conversion killers. It flags issues like a low-contrast CTA button, a pricing card that clips on iPhone SE, a missing restore purchases link, or an annual plan that looks identical to the monthly one. Get a list of fixes you can apply instantly with a single follow-up prompt.</p>
<h2>Try the AI Editor today</h2>
<p>Log into your dashboard, click “Create paywall,” and select “Generate with AI”. Describe what you want to build. Try to beat 3 minutes and 16 seconds. We think you will.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[The Hail Mary pitch that launched a #1 App Store hit]]></title>
      <link>https://www.revenuecat.com/blog/growth/bria-sullivan-focus-friend-launched-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/bria-sullivan-focus-friend-launched-podcast-2026</guid>
      <pubDate>Wed, 06 May 2026 13:16:16 GMT</pubDate>
      <dc:creator><![CDATA[Charlie Chapman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Bria Sullivan pitched a focus timer to Hank Green at the end of a meeting that was already over — and it hit #1 on the App Store.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/df2169ee0d19fb8d4428c8b3c2a961f75f2cefba-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p><a href="https://www.youtube.com/watch?v=RfCIikVJBkw">Watch on YouTube</a></p>
<h2>The Hail Mary pitch</h2>
<p>Bria Sullivan had already found success with her game Boba Story, but she wanted to experiment with monetizing a creator’s audience through an app. After a chance dinner meeting with YouTuber Hank Green, she secured a pitch meeting. The goal was to build something for one of his educational channels, Crash Course.</p>
<p>The meeting didn’t go well. The team wasn’t interested, and the answer was a polite “no.” But as the call was wrapping up, Bria threw out one last idea: “What about a Focus Timer, a Crash Course focus timer?”</p>
<p>That single question changed the trajectory of the project. While the Crash Course team still passed, Hank Green himself loved the concept. An hour later, he texted her to say he couldn’t stop thinking about it. They decided to partner up and build it specifically for his core audience—the “Nerdfighters.” It was a reminder that sometimes the best opportunities come from simply putting yourself in the room and refusing to let the meeting end on a rejection.</p>
<h2>Live-streaming user research on TikTok</h2>
<p>Rather than building in a vacuum, Bria used her existing TikTok audience to validate the app’s design in real time. During long live streams, she would draw different art styles, show color palettes, and ask her viewers directly what they preferred.</p>
<p>She presented six different variations of the app’s central character—a “bean”—and let the audience guide the direction. “When it comes to female audiences too and anything gamified, the look and feel matters so much for organic reach,” she explains. “It’s like what’s going to make someone convert, and the way something looks is a huge reason why people convert.”</p>
<p>This public validation ensured that by the time the app was ready, it already matched the exact aesthetic preferences of its target users. The original, lo-fi “Bee and Puppycat” style she initially favored was scrapped entirely based on this immediate, unfiltered feedback.</p>
<h2>The backlash of monetizing a fanbase</h2>
<p>Partnering with a massive creator solves the distribution problem, but it introduces a unique set of challenges. When Focus Friend quietly launched via a YouTube community post, the initial reaction wasn’t purely celebratory.</p>
<p>Despite getting 20,000 downloads in the first week, Bria was flooded with angry emails and negative reviews. Users were upset that the app used a subscription model instead of a lifetime unlock, and they were highly sensitive to standard app practices like ad tracking. Because the app was tied to Hank Green—a beloved public figure known for his philanthropy—users felt a personal betrayal that they wouldn’t feel toward a faceless corporation.</p>
<p>“I learned that,” Bria says. “Every app on your phone uses those things, but for some reason he’s not allowed to.” To protect Hank’s reputation and maintain trust, they made the difficult decision to strip out advertising tracking IDs entirely. It was a move that essentially killed their ability to do paid user acquisition, but it preserved the core relationship with the audience.</p>
<h2>Why creators make terrible product managers</h2>
<p>Bria’s experience with Focus Friend taught her a crucial lesson about creator partnerships: influencers rarely know what makes a good standalone app.</p>
<p>“I don’t think that influencers really know what a good idea is for them to do for their audience,” she observes. “They always, for some reason, they always want a social media or they want a feed of information. And I’m just like, no, I just don’t think that those are good ideas for apps personally.”</p>
<p>Her advice to developers looking to partner with creators is to act as the product manager. Creators understand their audience’s content preferences, but developers understand utility. The most successful collaborations happen when the developer steers the product toward a clear, functional use case—like a focus timer—rather than trying to build another content feed.</p>
<p>In <a href="https://www.youtube.com/watch?v=RfCIikVJBkw">the full episode</a>, Bria also talks about her journey from learning Android development in 2010 to winning an App Store Award, the reality of being a solo developer while raising a newborn, and why she believes product instinct is far more valuable than pure engineering skill.</p>
<h2>Guest links:</h2>
<ul>
<li><a href="https://www.linkedin.com/in/briasullivan/">Bria Sullivan on LinkedIn</a></li>
<li><a href="https://apps.apple.com/us/app/focus-friend-study-timer/id6450682289">Focus Friend on the App Store</a></li>
<li><a href="https://apps.apple.com/us/app/boba-story/id1535742211">Boba Story on the App Store</a></li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[AI-generated ads: balancing attention and trust in user-generated content]]></title>
      <link>https://www.revenuecat.com/blog/growth/ad-generated-ads-ugc</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/ad-generated-ads-ugc</guid>
      <pubDate>Wed, 06 May 2026 13:02:26 GMT</pubDate>
      <dc:creator><![CDATA[Anthony Morcillo]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[What actually works in synthetic ads, and how to juggle the boundary between algorithmic success and brand trust]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/2643918aac022bb5c68b9a6c491f04c062aca7a9-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>User-generated videos are no longer exclusively <em>user</em> generated.</p>
<p>With the rise of AI, advertisers can now generate creator-style ads in a matter of hours using synthetic avatars, automated voiceovers, and video tools. What used to require weeks of coordination and creator involvement can now be executed in an afternoon.</p>
<p>This shift is not just technological, it reflects a deeper change in how advertising operates — the pace of modern platforms has outgrown traditional content production.</p>
<p>But this raises a fundamental question. If content no longer needs to be created by real people to perform, what actually drives trust, and where does artificial intelligence start to break it?</p>
<p>Based on what we’ve tested at Mojo, the answer is more nuanced than the current hype suggests. But there’s a few key learnings:</p>
<ul>
<li>Replication beats interpretation</li>
<li>The cost of failure has collapsed</li>
<li>AI knows what looks real, but humans know what feels real</li>
<li>You are no longer building an ad; you are building a system</li>
</ul>
<h2><strong>The real driver: ad fatigue</strong></h2>
<p>The rise of AI-generated ads is not driven by novelty alone. While it is undeniably fueled by the fact that technology has finally crossed a critical threshold of fidelity, the primary catalyst for its adoption is a structural constraint: ad fatigue</p>
<p>On platforms like TikTok and Meta, videos are consumed at a relentless pace. A highly-effective ad can lose its impact within days. For subscription-based products, this rapid decay directly increases the cost of acquiring a new customer.</p>
<p>Today, those stakes couldn’t be higher — with RevenueCat data showing <a href="https://www.revenuecat.com/state-of-subscription-apps/">the top 25% of subscription apps grew 80% year-over-year in 2026</a>, while the bottom 25% shrank by 33%. In a winner-take-more market, efficient acquisition isn’t just about optimization, it’s about your app surviving at all.</p>
<p>The natural response is to produce more videos. But this is where the traditional system breaks:standard production is slow and requires a lot of resources. Even highly efficient teams operate on multi-week cycles. Meanwhile, the algorithms shift in real time.</p>
<p>This creates a fundamental mismatch: <strong>the algorithm evolves faster than your production process</strong>. AI removes that constraint. Instead of asking what the next ad should be, teams can now ask how many variations they can test today. The primary advantage of AI is not that it produces better ads. It’s that it allows you to discover better ads faster.</p>
<h2>Case study: Scaling the Mojo ‘Auto Edit’ winner</h2>
<p>At Mojo, one of our highest-performing ads was a simple 30-second split-screen video featuring a speaker explaining the product on top and a screen recording of the app on the bottom. This video converted trial users to paid at 23%.</p>
<p>The secret was the person. Our creator was our Product Manager. When he explained the product, he was walking through something he deeply understood. His conviction came from ownership. That kind of authenticity is extremely difficult to synthesize from scratch.</p>
<p><a href="https://fast.wistia.com/medias/2cungs4wsi">Watch on Wistia</a></p>
<p><em>Baseline: Our top-performing human UGC. Notice the genuine conviction of our PM talking about the feature he built.</em></p>
<h3><strong>Phase 1: why replication outperformed native creators</strong></h3>
<p>To scale this ad internationally, we initially recruited local creators in several countries to recreate the video. The result was disappointing — despite strong production quality, performance dropped across markets.</p>
<p>The issue came down to execution: each creator interpreted the script slightly differently. Pauses were shortened, pacing changed, and gestures were misaligned. Individually, these differences seemed minor, but collectively, they killed performance.</p>
<p>There was also a constraint regarding legal rights. We often did not have the rights to extend a creator’s content into other languages. This highlighted a critical point: <strong>the best-performing creator is the one you actually own.</strong></p>
<p>So we changed strategy. Instead of adapting the ad, we replicated it: we used video translation tools like HeyGen to dub the original video, while strictly preserving the timing, the pauses, the gestures, and the exact energy. In Brazil, the dubbed version achieved a 40% lower acquisition cost compared to native creators.</p>
<p>The lesson here is that execution mechanics matter just as much as the message. AI does not reinterpret; it preserves.</p>
<p><a href="https://fast.wistia.com/medias/8o7j5p7qcz">Watch on Wistia</a></p>
<p><em>AI Dubbing: 95% perfect lip-sync preserving our PM’s original high energy.</em></p>
<h3><strong>Phase 2: AI avatars (the 80% failure rate)</strong></h3>
<p>Encouraged by these results, we moved to fully AI-generated avatars. We expected the speed of production to compensate for a slight dip in quality. Instead, the outcome was binary: most of the profiles we tested failed almost immediately.</p>
<p>The generic avatars we initially generated did not work, and the reasons went beyond mere technical fidelity.</p>
<p>First, we encountered a fundamental <strong>perspective and staging issue</strong>. As you can see in the variations above, the avatars were placed in hyper-polished environments — studio microphones, cinematic lighting, and stylized backgrounds. Some were even positioned at slight three-quarter angles rather than looking directly into the lens. This immediately broke the native, informal visual code of UGC. The user’s brain categorized the content as a commercial within the first second.</p>
<p>Second, there was a major <strong>casting issue</strong>. The avatars simply did not match our Product Manager’s original profile or conversational energy. We were trying to scale a specific type of raw, founder-led credibility, but we were using avatars that looked like generic stock models or polished influencers. The disconnect between the message and the messenger was jarring.</p>
<p>These flaws, combined with the expected rigid expressions, caused rapid drop-offs. One avatar had a mere 0.2-second lip-sync delay on a key word, which resulted in a 68% drop in click-through rate compared to the human baseline. Users couldn’t articulate why they did not trust it, but the data was brutal.</p>
<p><strong>But one avatar worked.</strong></p>
<p>Instead of using a generic, polished profile, we used HeyGen to create a custom digital twin based strictly on our original Product Manager. We stripped away the studio mics and cinematic lighting. We matched his raw facecam perspective, his specific look, and his exact baseline energy.</p>
<p>Because the source material already had natural presence and inherent credibility, the output felt believable. That version reached <strong>87% of the original human conversion rate.</strong></p>
<p>The economics changed dramatically:</p>
<ul>
<li>Traditional creator: <strong>$500</strong></li>
<li>Generating the custom AI avatar: <strong>$20</strong></li>
</ul>
<p><strong>RESULT: 31% lower cost per acquisition.</strong></p>
<p><a href="https://fast.wistia.com/medias/60djmtbbvs">Watch on Wistia</a></p>
<p><em>The $20 Winner: Natural head tilts and accurate micro-expressions driving a 31% lower CPA.</em></p>
<h3><strong>Phase 3: the double AI stack</strong></h3>
<p>Once we identified a <a href="https://www.revenuecat.com/blog/growth/informed-empathy-user-interviews-ad-creatives/">winning creative</a>, we introduced a two-step process: generating the base video using the custom avatar, then instantly translating it into multiple new languages using <a href="https://www.revenuecat.com/blog/growth/jack-tanmay-elevenlabs-sub-club-podcast-2026/">ElevenLabs</a> for localized, natural-sounding voice cloning.</p>
<p><a href="https://fast.wistia.com/medias/dlqbge03we">Watch on Wistia</a></p>
<p><strong>The results (Brazil localized metrics):</strong></p>
<ul>
<li><strong>CPA:</strong> $8 (31% lower than the human control)</li>
<li><strong>CTR:</strong> 4.5%</li>
<li><strong>Conversion Rate:</strong> 3.1%</li>
<li><strong>ROAS:</strong> 2.1x</li>
</ul>
<p><strong>The cultural limit:</strong> This ‘double layer’ stack (a synthetic avatar that is then dubbed into another language) worked flawlessly in Brazil and Spanish-speaking markets, where users are highly receptive to direct-response UGC formats. However, when we pushed this exact same dubbed avatar to Europe (France, Germany), it failed. The European audience was far more sensitive to the uncanny valley, and the double layer of AI broke their trust.</p>
<p>This highlighted to us that sensitivity to artificial media is not just technical. It is geographic and culturally dependent. You can’t assume universal adoption.</p>
<h2><strong>Key takeaways: how AI-generated UGC impacts ad performance and trust</strong></h2>
<p>Ads are our business, so we had the luxury of being able to test AI-generated ads and finesse the output. Many apps won’t be able to take this same risk and time, so here’s my top learnings from our creative experiments with AI <a href="https://www.revenuecat.com/blog/growth/ugc-ads-apps/">user-generated content</a>.</p>
<h2><strong>Why natural execution beats polished authenticity</strong></h2>
<p>There is a common assumption that videos made by users perform well because they’re authentic. In practice, authenticity is only part of the story. These videos work because — even when users are following a script — they <em>feel</em> natural.</p>
<p>The best-performing ads rely on familiar patterns like direct-to-camera delivery, informal conversational tones, and simple visuals. AI is highly effective at replicating these patterns. By mimicking the structure of successful content, AI-generated ads can improve early metrics like views and watch time.</p>
<h2><strong>Balancing algorithmic attention vs. user trust</strong></h2>
<p>One of the most important insights from our experiments is that these ads operate across two distinct layers:</p>
<ol>
<li>The algorithm rewards <strong>attention</strong></li>
<li>The user decides based on <strong>trust</strong></li>
</ol>
<p>AI helps you win the first, but it doesn’t guarantee the second.</p>
<p>In our tests, fully synthetic avatars struggled in formats that rely on credibility, especially testimonials or personal stories. People are sensitive to anything that feels artificial in emotional contexts. We subconsciously scan for micro-expressions, subtle changes in eye contact, and the natural hesitation that signals genuine human experience. When an algorithm tries to simulate a heartfelt story, it misses these imperceptible cues. The result is a subconscious rejection by the viewer.</p>
<p>Conversely, AI thrives in structured, utility-driven formats. For product demonstrations, tutorials, and feature breakdowns, clarity and pacing matter far more than emotional authenticity. The rule is simple: <strong>use AI to explain, and use humans to convince</strong>. This principle was at the heart of our strategy when we decided to scale our own top-performing content at Mojo.</p>
<h2><strong>The economics of failing fast</strong></h2>
<p>The real advantage of AI was not just cost. It was speed.</p>
<p>With traditional methods, <a href="https://www.revenuecat.com/blog/growth/subscription-app-creative-testing/">creative testing</a> five human creators takes $2,500 and three weeks of production to find one potential winner. Testing five AI variations takes $100 and just two hours.</p>
<p>The downside of <a href="https://www.revenuecat.com/blog/growth/overanalyze-creative-analysis-paid-ads/">testing creatives</a> has collapsed. You’re no longer optimizing for perfect execution upfront, you’re optimizing for iteration speed. You are no longer buying videos, you’re buying iterations — and iteration compounds.</p>
<h2><strong>From production to decision-making</strong></h2>
<p>Before, production was the bottleneck. Now, production is instant. But a new bottleneck emerges: decision-making.</p>
<p>When you only have five videos a month, you can rely on intuition. When you can generate 50 variations a day, intuition fails. Teams no longer need to figure out how to produce content. They need to figure out what to test and what to cut.</p>
<p>AI did not remove the need for creativity. It made taste and data analysis the new competitive advantages. Bad decisions now scale just as fast as good ones.</p>
<h2><strong>The hidden risks</strong></h2>
<p>Like any new approach to <a href="https://www.revenuecat.com/blog/growth/creative-fatigue-mobile-apps-roas/">ad creatives</a>, there are payoffs you have to consider:</p>
<h3>Cookie-cutter ads</h3>
<p>We’re already seeing a specific aesthetic emerge among AI-generated ads: perfect lighting, lack of breathing pauses, and synthetic enthusiasm. If every brand starts using similar avatars, these videos will become the new fatigued format. <a href="https://www.revenuecat.com/blog/growth/detect-ad-fatigue-mobile-apps/">Ad fatigue</a> won’t disappear; it just shifts from the individual video to the format itself.</p>
<h3>Navigating the legal maze</h3>
<p>There’s also a significant legal challenge. Building your acquisition engine on a digital copy of a real person is a liability maze. Who owns the likeness if that employee leaves the company?</p>
<p>With external creators, the problem is worse. If you don’t secure rights to use their digital likeness, you’re essentially renting your growth. <strong>Owned content scales, rented content does not.</strong> Brands need to rethink their talent contracts entirely. You need specific likeness agreements that outline exactly how, where, and for how long a synthetic avatar can be used, including clauses that allow the company to run the digital copy for six to twelve months after an employee departs.</p>
<h3>Audience backlash and reputational risk</h3>
<p>The risk of audience backlash is a reality that cannot be ignored. Audiences are becoming increasingly sophisticated at identifying synthetic media, and for subscription-based apps, the stakes are uniquely high. Because the business model relies on an ongoing relationship, being perceived as using deceptive or low-quality AI <a href="https://www.revenuecat.com/blog/growth/creative-volume-meta-ad/">creative at volume</a> can be devastating.</p>
<p>It creates what is essentially a ‘trust tax’, if a user feels tricked by an advertisement, they might install the app, but will inevitably churn. It doesn’t just hurt immediate acquisition; it erodes the fundamental trust that drives long-term retention. Every app audience is different, so it’s vital to consider your specific consumer group’s sentiment. The challenge for brands moving forward is finding the balance between being transparent, efficient, and maintaining a sincere human connection.</p>
<h2><strong>Best practices for scaling AI-generated ads</strong></h2>
<p>Before you generate new AI content, you need to answer a few fundamental questions:</p>
<ul>
<li>Do you own the source material and likeness rights?</li>
<li>Is the format utility-driven rather than an emotional story?</li>
<li>Can you kill the campaign in 24 hours if the data shows it is failing?</li>
<li>Have you tested your specific market’s tolerance for synthetic media?</li>
</ul>
<p>Most importantly, you have to <strong>break the perfection</strong>. Perfect delivery feels like a commercial. Here’s what we’ve found works:</p>
<ul>
<li>Lower the resolution slightly so it does not look like a studio shoot</li>
<li>Let the audio retain a bit of background room tone.</li>
</ul>
<p>Artificial intelligence naturally gravitates toward a flawless output, so you have to actively force it to be messy.</p>
<h3>Checklist: when to use AI or real users for ads</h3>
<p>If you’re deciding between a human creator and a synthetic avatar, use this quick reference guide based on our testing:</p>
<p><strong>Opt for AI-generated ads when:</strong></p>
<ul>
<li><strong>The goal is utility:</strong> product walkthroughs, feature demos, or screen-recording tutorials</li>
<li><strong>You need scale: </strong>testing 10+ hook variations or localizing an ad into five different languages</li>
<li><strong>Speed is critical: </strong>you need to respond to a creative trend within 24 hours</li>
<li><strong>Cost is the bottleneck: </strong>you have a winning script but a limited budget for multiple creators</li>
</ul>
<p><strong>Opt for real humans when:</strong></p>
<ul>
<li><strong>The goal is trust: </strong>personal testimonials, ‘storytime’ formats, or founder-led brand intros</li>
<li><strong>Emotional nuance is key: </strong>content that requires subtle micro-expressions or genuine empathy</li>
<li><strong>High-value markets: </strong>targeting regions (like Europe) with high sensitivity to ‘uncanny valley’ media</li>
<li><strong>Originality: </strong>creating the source material that will eventually be replicated by AI</li>
</ul>
<h2><strong>The new UGC</strong></h2>
<p>We’re entering an era of infinite content where the true bottleneck is no longer creation, but judgment. While the technological barrier to generating an ad has effectively dropped to zero, <strong>the psychological barrier to earning consumer trust has never been higher</strong>.</p>
<p>Algorithms are exceptionally good at replicating familiar patterns to buy attention, compress feedback loops, and scale distribution. What they cannot do, however, is synthesize genuine conviction. The top growth teams of the future won’t merely be those who test the fastest. Instead, they will be the ones who understand exactly when to automate a system for efficiency, and when to rely on a real human heartbeat for persuasion.Ultimately, AI didn’t make advertising smarter — it simply revealed the clear boundary between what machines can execute and what they cannot. <strong>Speed and automation will always buy you attention, but it takes a human to earn trust</strong>.</p>
<p>Listen on: <a href="https://www.youtube.com/watch?v=3r8pr9w_lDQ">YouTube</a> · <a href="https://open.spotify.com/episode/3gC8n6pbe52ppPB0BWLyTa?si=feaba85b2af14556">Spotify</a> · <a href="https://podcasts.apple.com/gb/podcast/how-elevenlabs-turns-feature-launches-into-a-growth/id1538057974?i=1000753972691">Apple Podcasts</a></p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[The post-purchase screen: how to stop Day 0 cancellations]]></title>
      <link>https://www.revenuecat.com/blog/growth/post-purchase-screen</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/post-purchase-screen</guid>
      <pubDate>Tue, 05 May 2026 10:21:34 GMT</pubDate>
      <dc:creator><![CDATA[Daphne Tideman]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[How to stop losing subscribers before they even start the trial]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/d7231ab61a615bfa74d125fb0dbfd6ef7173b205-1600x800.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>There are a lot of things that keep me up at night: my ADHD medication, whether I locked the back door, my dog trying to get on the bed… and these trial cancellation numbers. Yep, stats like these are enough to steal my Zs (and now they’re about to do the same to you, sorry in advance).</p>
<p>A staggering 55.4% of 3-day trials are cancelled on Day 0. Ouch.</p>
<p>That’s over half gone before they’ve even had a chance to use what they signed up for. Talk about not giving an app a fair shot. So what’s happening, and how can apps combat it?</p>
<p>Whilst the number does drop a bit with longer trials, even with a 30-day trial nearly a third of <strong>users cancel on the very first day</strong>. Combine Day 0 and Day 1, and you’re looking at <strong>84% of 3-day trial cancellations happening in that first 24 hours.</strong> For 7-day trials, it’s still 64%.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/e34a2e869f9eaf555a54bba8cd4191b999abb64e-3240x1954.png" alt=""/><figcaption>% of trial cancellations, by day and trial duration — State of Subscription Apps 2026</figcaption></figure>
<p>Do you see why those numbers keep me up at night?</p>
<p>And it’s not just trials, even annual subscriptions see <strong>almost 35% of cancellations within the first month.</strong></p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/be96829f00def1727e0d1924f883a38e7624d451-3236x1946.png" alt=""/><figcaption>Cancellation timeline for annual subscriptions — State of Subscription Apps 2026</figcaption></figure>
<h2><strong>Why 55% of free trials cancel on day zero</strong></h2>
<p>This data from the <a href="https://www.revenuecat.com/state-of-subscription-apps/">State of Subscription Apps Report 2026</a> really surprised me. <a href="https://www.revenuecat.com/blog/growth/subscription-app-churn-reasons-how-to-fix/">Users are churning fast</a>, losing faith in the app from the outset, feeling disappointed. It’s like your date slipping out after the first drink. You’re left wondering… how did it go so wrong so fast?</p>
<p>The first place we tend to look is <strong>onboarding</strong>. While I’ve seen plenty of wins from <a href="https://www.revenuecat.com/blog/growth/fix-onboarding-funnels/">improving onboarding and paywalls</a>, it’s not enough. These early cancellations show a clear pattern: people <em>want</em> to try your product. They hand over their payment details, click subscribe… and then something goes wrong immediately after.</p>
<p>Most advice on reducing early cancellations focuses on the in-app experience, pre-trial onboarding or <a href="https://subclub.com/episode/how-to-boost-retention-with-subscription-lifecycle-messaging-alice-muir-phiture">improving your lifecycle marketing</a>. All this is important, but there’s one step almost nobody talks about: <strong>the post-subscription screen.</strong> The very thing your users see the moment after they convert.</p>
<h3><strong>Introducing app aftercare: aka, the post-purchase screen</strong></h3>
<p>I first realized this when I interviewed <a href="https://www.revenuecat.com/blog/growth/how-top-apps-approach-paywalls/">Rosie Hoggmascall, Head of Product &amp; UX at Fyxer.ai, on her approach to paywalls</a>. She emphasized thinking about <strong>what happens </strong><em><strong>after</strong></em><strong> purchase</strong>, and it stuck with me. I call it aftercare, because that’s exactly what most apps are missing: care.</p>
<p>Think about it like this: you walk into a shop, buy something, and the minute your transaction goes through, the salesperson turns away. No “Great choice!”, no bag, no receipt, no guidance on how it works. Just… nothing. You’d feel weirdly used. Embarrassed. Probably just awkwardly standing there wondering, “Now what?”.</p>
<p>That’s exactly what most apps do.</p>
<p>I walked through a ton of post-purchase screens to see how different apps handle this moment. (Confession: I didn’t spend hundreds of pounds on subscriptions. I have to be very strict on my app subscriptions, given how many I test. So I used <a href="https://mobbin.com/">Mobbin</a> for examples, though I did peek at some in the wild too.) What I found was a spectrum: some apps completely miss the moment, and some absolutely nail it.</p>
<h2><strong>6 Levels of post-purchase screen optimization</strong></h2>
<p>Here’s how to level up your post-purchase screen, step by step. I see six levels of post-purchase screens:</p>
<ol>
<li>The basic version: straight into the app</li>
<li>A clear confirmation screen</li>
<li>Celebrate the moment</li>
<li>Remind them what they are getting</li>
<li>Highlight what you’ll help them achieve</li>
<li>Give them one clear next step</li>
</ol>
<p>Each is slightly better than the last, and more in tune with what the user needs.</p>
<p>Now, I will say that as much as I love a neat little framework, some apps I reviewed broke the mould, choosing to use the post-purchase moment to push users to sign up for an account or pursue an upsell. That also works in certain scenarios, so I’ll cover when to go with those approaches as well.</p>
<p>I’ve also made a Mobbin board with 13 examples of different app onboarding-to-post-purchase screen journeys. <a href="https://mobbin.com/collections/8cfbacbc-d7bf-4d77-85eb-2aeda12b9c44/mobile/flows?utm_source=share_link&amp;utm_medium=share&amp;utm_campaign=collection_sharing">Check it out here.</a></p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/1ee3f668ef6e973faed034c46934d5c2cc9ac088-2048x1209.png" alt=""/></figure>
<h3><strong>Level 1: the default straight-into-app version</strong></h3>
<p>The most common approach is to rely on Apple’s default “You’re all set” confirmation, then dump users straight into the app. That’s it. No intermediate screen, no acknowledgement of what just happened, no guidance on what to do next.</p>
<p>Take <a href="https://zerolongevity.com/">Zero</a>, a weightloss and metabolic health app. You subscribe, get the standard Apple pop-up, and then you’re deposited straight into the app. What next?</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/0f1d11e65a169a9cc55915f8f8dead8ed421fba0-1122x468.png" alt=""/></figure>
<p><a href="https://www.masterclass.com/">MasterClass</a>, the online learning platform, does the same. You could argue it makes sense — MasterClass has a massive library, and users potentially already know what they want to watch or were attracted by a specific expert to subscribe. But even here, a little extra care would go a long way. If someone clicks through that confirmation screen too quickly (and they will), they might not even register what they just signed up for — or how much they’re paying.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/51b83c493c66cfb35dcb3c458aecd32bc2f363d1-904x467.png" alt=""/></figure>
<p>It’s the same as the store example I shared earlier: no acknowledgement, no guidance, just an abrupt ending.</p>
<h3><strong>Level 2: a clear confirmation screen</strong></h3>
<p>Level two is a simple but meaningful upgrade — after the standard Apple confirmation, add your own screen that <strong>acknowledges what just happened</strong>.</p>
<p><a href="https://talkpillowtalk.com/">Pillow Talk</a>, an AI journaling app, does this really well. It welcomes you to Pillow Talk Plus, confirms that everything in the plan is now yours — the insights, the support, the space to process — and then guides you straight into the app.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/2dfe6839e83eeb162410d4a61d0615042e96c43e-905x477.png" alt=""/></figure>
<p>It doesn’t list every feature exhaustively, but it gives a reassuring tick that you made the right choice. It also helps clarify that your subscription went through, in case you clicked past the Apple pop-up too quickly (as I tend to do).</p>
<p><a href="https://www.lifesum.com/">Lifesum</a>, a calorie-counter and meal-planning app, takes a similar approach: “Your journey has begun”. Simple, warm, and focused on the outcome rather than the features. That small moment matters more than you might think, especially for someone on a trial who could still talk themselves out of it.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/3a5cac828cf47728f752c267183117f50c87a3f6-1255x530.png" alt=""/></figure>
<p>That little moment of “You made the right choice, here’s what’s ahead” can do a lot of heavy lifting. It’s the same as a salesperson in a store saying, “All set — enjoy!”</p>
<p>You can take it even further by reminding users of their original reason for signing up (their <a href="https://www.revenuecat.com/blog/growth/what-drives-users-to-pay-jobs-to-be-done/">Job to be Done</a>) to <strong>reassure them and reinforce why they’re here</strong>. It’s such a simple screen, and not a huge effort to implement, which is why I think this should be the minimum standard for any app.</p>
<h3><strong>Level 3: celebrate the moment</strong></h3>
<p>Now we can do even better. Be the store employee who doesn’t just say, “You’re all set,” but adds a little enthusiasm: “I have that shirt too” or “Those shorts have been so popular, I love them”. The tiny extra phrase that <strong>makes you feel good about your purchase</strong> (and less guilty about spending money when you swore off clothes shopping for the month… ahem, not talking about me here).</p>
<p>In-app, you can take the same approach: make the post-purchase screen feel like a genuine celebration. Not cringe or over-the-top, but a moment that acknowledges they made a good decision, and this is something worth celebrating.</p>
<p>This is what <a href="https://www.duolingo.com/">Duolingo</a> does. When I first explored this topic with Rosie, she mentioned their celebratory animation, which quickly makes you feel good about the decision you just made.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/7367eb73accf042bc5a425a872b296c78dbdca88-787x552.png" alt=""/></figure>
<p>I mean, we rarely see that owl so happy — it’s nice to see.</p>
<p>Animations are a great way to spark that energy. Take <a href="https://pages.kitchenstories.com/en/app">Kitchen Stories</a>, a recipe app: their fun confetti screen welcomes you with, “You can now start using Kitchen Stories Plus!” It could be a bit clearer about exactly what’s included in Plus, but the moment of celebration is there, and it works. (And there is confetti, who doesn’t love a bit of confetti.)</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/b3953068d862c52ecdad93d52d11b3b2d4cc71a2-1125x474.png" alt=""/></figure>
<h3><strong>Level 4: remind them what they’re actually getting</strong></h3>
<p>Here’s something app teams do too quickly: assuming users remember what’s included in the plan they just upgraded to. Spoiler: they don’t. Especially on a trial, where part of the decision might have been, “Well, I can always cancel”.</p>
<p>It’s like making a big order at a restaurant; it’s nice when the waiter repeats it back to you, reassuring you they’ve captured your order. The same applies here. Especially if the upgrade came through a feature-specific paywall, users might not even know all the premium features they now have access to.</p>
<p><strong>Use the post-purchase screen to remind the user of their purchase</strong>. It provides clarity, removes nerves, and also reminds them of all the great features now available.</p>
<p><a href="https://www.alltrails.com/">AllTrails</a>, the hike, bike, and run app, does this really well for their Peak subscription. First, there’s a simple animation welcoming you. Then, after suggesting push notifications, they clearly list everything included in the plan.</p>
<p>For users who were previously on Plus, they even reassure you that you’re still getting all the Plus features too.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/d130d8dc931080054049dc46fd4708b0dd9386d8-1264x536.png" alt=""/></figure>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/9c4a67324d57259e16f85c073fa11e70dcb572bc-1009x537.png" alt=""/></figure>
<p>That prompt to enable push notifications, with a clear explanation like “We’ll remind you before your trial ends”, is also a clever way to tackle Day 0 cancellations. It’s a double win: you get permission to send notifications, and the user feels less anxious about forgetting to cancel if they want to — <strong>preventing the pre-emptive trial cancellation</strong>.</p>
<p>This point about notifications is worth dwelling on. You can promise users reminders upfront, but if they haven’t enabled notifications yet, that promise doesn’t land. The post-purchase screen is the perfect moment to set this up, in context, when the reason is obvious, and the value is immediate.</p>
<h3><strong>Level 5: highlight what you’ll help them achieve</strong></h3>
<p><a href="https://www.beside.com/">Beside</a>, an AI receptionist app does this particularly well. It congratulates you on starting your trial, then immediately <strong>reminds you of the outcome you’re here for</strong>: your clients will get instant answers, and you’ll get time back.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/86bc6f3b03f3ee9a8bce4746536bff14dd9ee8d5-1006x531.png" alt=""/></figure>
<p>It also backs that up with three concrete stats:</p>
<ul>
<li>100% of business calls answered</li>
<li>80% of inquiries handled automatically</li>
<li>30% more bookings</li>
</ul>
<p>You haven’t even opened the app properly yet, and you’re already thinking about results, instead of fighting buyer’s remorse and wondering whether to cancel.</p>
<p><a href="https://www.makeheadway.com/">Headway</a>, the bitesized book summaries and personal growth app, is one of my favorites here. It combines the celebratory approach from earlier with a satisfying star animation: “Congrats! Now you are a member of Headway Premium, together with 1.7 million learners.”</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/c49f961a84e39dae6590625f41e75f496aaf2001-1007x532.png" alt=""/></figure>
<p>That <strong>community framing</strong> is smart. It immediately signals that this isn’t some scrappy app you should second-guess. 1.7 million people made the same choice you just did.</p>
<p>We often assume that because someone signed up for a trial or subscribed, they already trust us. But really, they’ve only trusted us enough to make that first purchase (that’s <em>free</em>!). The post-purchase screen is your chance to <strong>keep building that trust</strong>, step by step.</p>
<p>Then it reframes the moment around the <a href="https://www.revenuecat.com/blog/growth/what-drives-users-to-pay-jobs-to-be-done/">Job to Be Done</a>: “It’s Day 1 of your self-growth journey”. That’s more than a confirmation, it’s a feeling of excited expectation. This is a new me. I’ll finally stop doomscrolling Instagram and start learning!</p>
<p>Headway reinforces this by <strong>focusing on outcomes</strong> — grow your productivity, get daily motivation, improve your soft skills — rather than just listing features. It’s about making the user feel the value they just unlocked.</p>
<h3><strong>Level 6: give them a clear next step</strong></h3>
<p>For some apps, the biggest barrier after subscribing isn’t doubt, it’s paralysis. Where do I start? What should I do first? If your app has a lot of content or options, that first decision can feel overwhelming enough to make someone close it entirely.</p>
<p>Now you <em>don’t</em> want to hit users with ten questions right after purchase — I don’t know about you, but when that happens, I just blank or pick the first thing that comes to mind.</p>
<p>The solution isn’t <em>more information</em>, it’s <strong>fewer choices</strong>. <a href="https://www.greg.app/">Greg</a>, a plant care app, handles this brilliantly. The post-purchase screen gives you one job: start adding your plants. Take a photo or upload one. No menus to navigate, no features to explore, no extra decisions. Just <strong>one clear first step that gets you using the product immediately</strong>.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/25ab8abac0756d71ee08b84558c21a0cff604f8a-1121x472.png" alt=""/></figure>
<p>I had a similar experience with <a href="https://www.picnic.photos/">Picnic</a>, a photo-organizing app. The moment I subscribed, it took me straight to a folder of my 2017 photos and had me swipe left or right to decide what to keep — Tinder-style.</p>
<p>As someone who hasn’t used a dating app in ten years, I got to enjoy the fun of swiping again. Once I finished that folder, the app celebrated how much space I’d freed up and let me delete the photos. Then I could move to the next folder at my own pace, making the process clear, fun, and completely manageable.</p>
<p>What that did (and I don’t think it was accidental) was make me feel immediate progress. I wasn’t thinking about whether the weekly subscription was worth it; I was busy clearing out my 2017 camera roll. By the time I came up for air, I was already invested, and honestly, it was kind of fun sifting through years of photos.</p>
<p>Another approach is to provide a <strong>brief instruction on what to do next</strong>. After the celebratory owl, this is exactly what Duolingo does: a simple, clear set of steps showing how the app works and what to do first:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/c628ad6a7655db9446ee6b012b087a691522254a-1069x563.png" alt=""/></figure>
<p>While many apps include this guidance in their onboarding — and yes, <a href="https://www.revenuecat.com/state-of-subscription-apps/">most users will see it on Day 0</a> — for anyone signing up later, it’s a helpful reminder of what they’ll get and how it works.</p>
<h2><strong>Alternative post-purchase onboarding strategies</strong></h2>
<p>Now we’ve covered the six levels of app aftercare, let’s push it just a little further. Depending on your app, you can also use this moment to encourage users to create an account or present a subtle upsell, making the post-purchase screen do double duty.</p>
<h3><strong>1. Push for the account setup</strong></h3>
<p>If account creation is important to your app — for saving progress, personalizing the experience, or staying in touch — and you haven’t already collected it, the post-purchase screen is a good moment to ask for account creation. Not perfect, sure — ideally, you’d have it earlier, but better late than never.</p>
<p>The key is <strong>giving users a real reason to hand over their details right now</strong>. Not “create an account to continue” (that feels like a gate), but motivation to pass over that info. Back to our shop example: if setting up an account comes with a perk, like 10% off, we’re much more likely to go for it.</p>
<p>Alan Mind, a previous CBT-guided journaling app, does this well: “Save your progress and secure your journal”. That’s a reason that actually matters to the user, showing that their data and privacy are safe.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/d4f4af89e3161d2476c8075b16590972ce85d3c5-894x477.png" alt=""/></figure>
<p>What would make this even better is offering frictionless sign-up options, like Apple or Google. It removes effort at exactly the moment users are least motivated to fill out forms.</p>
<p>As it stands, it’s not entirely clear what the password requirements are either, which can make this step feel unnecessarily clunky, and that’s the last thing you want right after someone has just subscribed.</p>
<p>Calm, the meditation app approaches this a bit better:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/683fa0383e782a869eddd11837e86e9b913ebaea-1017x534.png" alt=""/></figure>
<p>Post-purchase, you get a <em><a href="https://www.revenuecat.com/blog/growth/how-did-you-hear-about-us-surveys/">how did you hear about us </a></em><a href="https://www.revenuecat.com/blog/growth/how-did-you-hear-about-us-surveys/">survey</a>, and are then encouraged to sign up for an account to track progress.</p>
<h3><strong>2. Go for the upsell</strong></h3>
<p>One final option — and one to use carefully — is leveraging the post-purchase high to push for a longer commitment or additional purchase. This tends to show up more in larger, well-known apps that are focused on increasing <a href="https://www.revenuecat.com/blog/growth/what-is-lifetime-value-ltv-apps/">realized LTV</a> per paying customer.</p>
<p><a href="https://flo.health/">Flo</a>, a cycle-tracking and women’s health app, does this with confidence. After congratulating you on starting Flo Premium, it presents a ‘gift’, which turns out to be a <a href="https://www.revenuecat.com/blog/growth/lifetime-subscriptions/">44% lifetime discount</a> on an <a href="https://www.revenuecat.com/blog/growth/annual-subscriptions-apps-pros-cons/">annual subscription</a>.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/3afc1adfd5005d2f34d489597e2cd467523c6d1c-1514x530.png" alt=""/></figure>
<p>The framing is smart: it’s positioned as a gift, not a sales pitch. And by locking in annual subscribers at the moment of highest trust, they’re tackling both trial drop-off and long-term churn in one move.</p>
<p>Headway takes a slightly different approach. After the celebratory screen, they offer a one-time deal on a Self-Reflection Ebook at a discounted price.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/eb212b497e41c0b4e97d6ca4214541638d0ff739-1006x527.png" alt=""/></figure>
<p>This is a more classic upsell moment, the kind you’d usually see in e-commerce. You’re already in a ‘yes’ mindset, trust is high, and a complementary product can feel genuinely helpful rather than pushy.</p>
<p>That said, sequencing matters. Focus on getting the basics right first, making sure users feel good about what they’ve just committed to, and only then, ask for more.</p>
<h2><strong>How to build your first post-purchase screen</strong></h2>
<p>Getting started is easy. If you’re currently at Level 1, don’t try to jump straight to Level 5, as tempting as it is. Start with Level 2. It’s a single screen you can ship in a sprint: acknowledge what just happened, remind users why they signed up, and make them feel good about it. That alone puts you ahead of most apps.</p>
<p>Once that’s live, start layering in improvements. Add a celebratory moment or a clear first step, depending on what your app needs. If your product has a lot of content and users tend to feel overwhelmed, prioritize a clear next step or simple instructions (like Duolingo). If your product is simple but the commitment feels big (like Headway), lean into celebration and reassurance.</p>
<p>The main <a href="https://www.revenuecat.com/blog/growth/activation-metrics/">metrics to watch</a> are your <strong>Day 0 and Day 1 cancellation rates</strong>. Track before and after launching the new screen to measure impact. If you offer a <a href="https://www.revenuecat.com/blog/growth/7-day-trial-subscription-app/">free trial</a>, also monitor your trial-to-paid conversion rate, especially if you’ve added things like push notification opt-ins or feature reminders (like AllTrails has).</p>
<p>If you go down the upsell route, focus on realized LTV and retention, not just the conversion rate on that single offer. A pushy upsell that damages trust will cost you far more in churn than it generates in upgrades.</p>
<p>Because ultimately, the goal isn’t to optimize the post-purchase screen in isolation, it’s to <strong>get the moment after someone subscribes </strong><em><strong>right</strong></em>, because that sets the tone for the entire relationship.</p>
<h2><strong>Don’t skip this onboarding moment</strong></h2>
<p>There are many ways to approach the post-purchase screen, and not every approach will fit every app. But the one thing I’d strongly push back on is doing nothing, dropping users straight into the app, and hoping they figure it out.</p>
<p>At a minimum, give users clarity on what they’ve just subscribed to. Add a moment of celebration, a clear next step, or a reminder of the outcome they’re working towards. These aren’t big product investments, but they can make a real difference to those Day 0 cancellation numbers.</p>
<p>You’ve already done the hard work of earning someone’s trust to the point that they’re willing to subscribe. Don’t stop guiding them the moment they do.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Compose Multiplatform subscriptions: single codebase for iOS and Android]]></title>
      <link>https://www.revenuecat.com/blog/engineering/cmp-subscriptions</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/engineering/cmp-subscriptions</guid>
      <pubDate>Thu, 30 Apr 2026 01:40:16 GMT</pubDate>
      <dc:creator><![CDATA[Jaewoong Eum]]></dc:creator>
      <category><![CDATA[Engineering]]></category>
      <description><![CDATA[This article walks through building a Kotlin Multiplatform app with the RevenueCat KMP SDK, covering setup, purchases, entitlement gating, and server-driven paywalls using the official cat-paywalls-kmp demo structure.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/f54134b2ef88a574bdae5bdcc71d0b6c0ae8b0da-1600x850.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>You ship a subscription app on Android and your team starts the iOS port. Suddenly you are maintaining two paywall implementations for Android and iOS, two billing integrations, and two sets of receipt verification code with different APIs and different bugs. RevenueCat’s <a href="https://github.com/RevenueCat/purchases-kmp">purchases-kmp SDK</a> collapses that duplication. You write your subscription logic once in <code>commonMain</code>, the SDK wraps Google Play Billing on Android and StoreKit on iOS, and a Compose Multiplatform paywall component renders the same UI on both platforms.</p>
<p>In this article, you’ll set up a Kotlin Multiplatform project with the RevenueCat KMP SDK, configure dashboard products and entitlements, initialize Purchases on Android and iOS, gate premium content from common code, run an in app purchase from <code>commonMain</code>, and drop in a server driven paywall built with the dashboard’s Paywall Editor. You’ll work directly with the same source layout used by <a href="https://github.com/RevenueCat/cat-paywalls-kmp">cat-paywalls-kmp</a>, the official KMP demo app.</p>
<h2><strong>What you’ll build</strong></h2>
<p>You’ll end up with a Compose Multiplatform app that lists premium articles, fades the body until the user is entitled, opens a server driven paywall, runs the purchase through the platform native dialog, and refreshes the entitlement state. The same screen runs on both iPhone and a Pixel without a single line of duplicated UI code.</p>
<p>The repository structure is a normal multi module KMP project: a <code>composeApp</code> module with <code>commonMain</code>, <code>androidMain</code>, and <code>iosMain</code> source sets, a few <code>feature</code> modules (home, article, paywalls, subscriptions), and <code>core</code> modules for data, network, and design system. Every line of subscription logic in this tutorial lives in <code>commonMain</code>.</p>
<h2><strong>Why single codebase changes the math</strong></h2>
<p>Building cross-platform subscriptions without a shared SDK means writing every subscription concept twice. You query Google Play with <code>BillingClient</code> and a purchase token, and you query StoreKit with <code>Product.products(for:)</code> and a signed JWS. You verify receipts with two completely separate server APIs, store them in two different shapes, and reconcile them with a backend mapping layer. None of this work is the part of your product that users care about.</p>
<p>The RevenueCat KMP SDK gives you four shared concepts that hide all of that:</p>
<ul>
<li><strong>Offerings</strong> are the set of products you currently sell, configured in the dashboard rather than in your app.</li>
<li><strong>Packages</strong> are the buyable units inside an offering (monthly, annual, lifetime).</li>
<li><strong>Entitlements</strong> are the access levels your app cares about (<code>premium</code>, <code>pro</code>, <code>family</code>), independent of which store the user paid through.</li>
<li><strong>CustomerInfo</strong> is a single object that aggregates every active entitlement for the current user, regardless of platform.</li>
</ul>
<p>Once you have these four objects, your app stops asking “did this user buy through Google Play or App Store?” and starts asking “is <code>customerInfo.entitlements[&quot;premium&quot;]</code> active?” That single property check works the same on iOS and Android.</p>
<h2><strong>Prerequisites and dashboard setup</strong></h2>
<p>Before any code, you’ll set up four things in the <a href="https://app.revenuecat.com/">RevenueCat dashboard</a>:</p>
<ol>
<li>A <strong>project</strong> with one Android app and one iOS app, each linked to its store credentials.</li>
<li><strong>Products</strong> imported from Google Play Console and App Store Connect.</li>
<li>An <strong>entitlement</strong> named <code>premium</code> (or whatever identifier you prefer).</li>
<li>An <strong>offering</strong> with one or more packages attached, marked as the current offering.</li>
</ol>
<p>Two codelabs walk through the dashboard side end to end. If you have not configured products yet, go through them first:</p>
<ul>
<li><a href="https://revenuecat.github.io/codelabs/google-play.html">RevenueCat Google Play Integration</a></li>
<li><a href="https://revenuecat.github.io/codelabs/app-store.html">RevenueCat App Store Integration</a></li>
</ul>
<p>The two important screens to land on are the entitlement and the offering. The entitlement is the unit your app code checks:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/81e1c36a16d72d1b96930f7c12e36c0fca11de58-2448x674.png" alt=""/></figure>
<p>The offering is what your app fetches at runtime. It bundles the packages you want this version of the app to display, and you can change its contents without shipping a new build:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/a2a6777363f8b24d31594354433032c3ae0b0c4a-1462x461.png" alt=""/></figure>
<p>After both apps are set up, copy the public SDK API keys from <strong>Project Settings &gt; API Keys</strong>. There is one key per platform, and you will use both of them in Step 2.</p>
<h2><strong>Step 1: Add the RevenueCat KMP SDK</strong></h2>
<p>The KMP SDK ships as two artifacts: <code>purchases-kmp-core</code> for the subscription logic and <code>purchases-kmp-ui</code> for the Compose Multiplatform paywall component. Both are published to Maven Central with a single version coordinate of the form <code>&lt;sdk&gt;+&lt;hybrid-common&gt;</code>. The number after the plus sign matters when you link the iOS pod, so do not strip it.</p>
<p>Start by adding the version and library entries to <code>gradle/libs.versions.toml</code>:</p>
<pre><code class="language-kotlin">[versions]
purchases-kmp = &quot;2.10.2+17.55.1&quot;

[libraries]
purchases-kmp-core = { module = &quot;com.revenuecat.purchases:purchases-kmp-core&quot;, version.ref = &quot;purchases-kmp&quot; }
purchases-kmp-ui   = { module = &quot;com.revenuecat.purchases:purchases-kmp-ui&quot;,   version.ref = &quot;purchases-kmp&quot; }</code></pre>
<p>Then declare the dependencies inside the <code>commonMain</code> source set of your app module’s <code>build.gradle.kts</code>. In the cat-paywalls-kmp demo this lives in <code>composeApp/build.gradle.kts</code>:</p>
<pre><code class="language-kotlin">kotlin {
  sourceSets {
    commonMain.dependencies {
      \/\/ RevenueCat
      implementation(libs.purchases.kmp.core)
      \/\/ Compose Multiplatform paywall component
      implementation(libs.purchases.kmp.ui)
    }
  }
}</code></pre>
<p>Notice that both Android and iOS pull the same <code>commonMain</code> dependency. There is no <code>androidMain.dependencies { implementation(&quot;com.revenuecat...&quot;) }</code> block. The KMP artifact carries platform specific bindings inside it.</p>
<h3><strong>Linking the iOS native framework</strong></h3>
<p>The KMP SDK depends on <code>PurchasesHybridCommon</code>, a native iOS framework that wraps StoreKit. The cleanest way to bring it in is through the Kotlin CocoaPods plugin. Apply the plugin to your <code>composeApp</code> module:</p>
<pre><code class="language-kotlin">plugins {
  alias(libs.plugins.kotlin.multiplatform)
  alias(libs.plugins.compose.multiplatform)
  alias(libs.plugins.compose.compiler)
  kotlin(&quot;native.cocoapods&quot;)
}</code></pre>
<p>Then declare both pods inside the <code>kotlin { cocoapods { ... } }</code> block. Pin the pod version to the same major as your <code>purchases-kmp</code> artifact so the Kotlin and Swift sides agree on protocol shapes:</p>
<pre><code class="language-kotlin">kotlin {
  cocoapods {
    summary = &quot;Cat Paywalls KMP App&quot;
    homepage = &quot;&lt;https:\/\/github.com\/revenuecat\/cat-paywalls-kmp&gt;&quot;
    version = &quot;1.0&quot;
    ios.deploymentTarget = &quot;15.0&quot;
    podfile = project.file(&quot;..\/iosApp\/Podfile&quot;)

    framework {
      baseName = &quot;ComposeApp&quot;
      isStatic = true
    }

    pod(&quot;RevenueCat&quot;) {
      version = &quot;~&gt; 5.21&quot;
      extraOpts += listOf(&quot;-compiler-option&quot;, &quot;-fmodules&quot;)
    }
    pod(&quot;RevenueCatUI&quot;) {
      version = &quot;~&gt; 5.21&quot;
      extraOpts += listOf(&quot;-compiler-option&quot;, &quot;-fmodules&quot;)
    }
  }
}</code></pre>
<p>Run <code>./gradlew podInstall</code> once. Gradle generates a <code>.podspec</code> for your shared module and writes a <code>Podfile.lock</code> next to your iOS app. From now on, opening the iOS workspace in Xcode pulls everything down through CocoaPods.</p>
<p>The KMP SDK uses Kotlin/Native interop bindings that are still flagged as experimental, so opt in inside any iOS source set that touches them:</p>
<pre><code class="language-kotlin">kotlin {
  sourceSets {
    all {
      languageSettings {
        if (name.startsWith(&quot;ios&quot;)) {
          optIn(&quot;kotlinx.cinterop.ExperimentalForeignApi&quot;)
        }
      }
    }
  }
}</code></pre>
<p>That is the entire setup. No platform-specific source files yet. Everything else lives in <code>commonMain</code>.</p>
<h2><strong>Step 2: Initialize Purchases on each platform</strong></h2>
<p><code>Purchases</code> is a singleton. You configure it once early in the app lifecycle, then every other call goes through <code>Purchases.sharedInstance</code>. Initialization is the only place where Android and iOS code differ, and only because each platform has its own application entry point.</p>
<p>On Android, initialize from your <code>Application.onCreate</code>. The cat-paywalls-kmp demo does this in <code>CatArticlesApplication</code>:</p>
<pre><code class="language-kotlin">class CatArticlesApplication : Application() {

  override fun onCreate() {
    super.onCreate()

    Purchases.logLevel = LogLevel.DEBUG
    Purchases.configure(
      PurchasesConfiguration(apiKey = REVENUECAT_ANDROID_API_KEY) {
        appUserId = null \/\/ Anonymous user
      },
    )
  }

  companion object {
    private const val REVENUECAT_ANDROID_API_KEY = &quot;your_android_api_key&quot;
  }
}</code></pre>
<p>The <code>PurchasesConfiguration(apiKey) { ... }</code> builder is a small DSL. Inside the trailing lambda you can set <code>appUserId</code>, <code>purchasesAreCompletedBy</code>, <code>verificationMode</code>, and other options. Passing <code>appUserId = null</code> tells the SDK to generate an anonymous identifier in the form <code>$RCAnonymousID:&lt;uuid&gt;</code>. When the user later signs in to your backend, you call <code>Purchases.sharedInstance.logIn(userId)</code> and RevenueCat transfers any purchases to that account.</p>
<p>Do not forget to register the <code>Application</code> class and the <code>INTERNET</code> permission in <code>AndroidManifest.xml</code>:</p>
<pre><code class="language-javascript">&lt;application
    android:name=&quot;.CatArticlesApplication&quot;
    android:label=&quot;@string\/app_name&quot;&gt;
    &lt;activity android:name=&quot;.MainActivity&quot; android:exported=&quot;true&quot;&gt;
        &lt;intent-filter&gt;
            &lt;action android:name=&quot;android.intent.action.MAIN&quot; \/&gt;
            &lt;category android:name=&quot;android.intent.category.LAUNCHER&quot; \/&gt;
        &lt;\/intent-filter&gt;
    &lt;\/activity&gt;
&lt;\/application&gt;

&lt;uses-permission android:name=&quot;android.permission.INTERNET&quot; \/&gt;</code></pre>
<p>On iOS, initialize from your SwiftUI <code>App</code> struct. You import the native <code>RevenueCat</code> pod here, not the KMP wrapper, because configuration runs in Swift before the Kotlin runtime starts:</p>
<pre><code class="language-swift">import SwiftUI
import RevenueCat

@main
struct iosAppApp: App {

    init() {
        Purchases.logLevel = .debug
        Purchases.configure(withAPIKey: &quot;your_ios_api_key&quot;)
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}</code></pre>
<p><code>ContentView</code> then hosts the shared Compose surface inside a <code>UIViewControllerRepresentable</code>:</p>
<pre><code class="language-swift">struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -&gt; UIViewController {
        MainViewControllerKt.MainViewController()
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}</code></pre>
<p>The Kotlin side of that bridge is a one liner in <code>iosMain</code>:</p>
<pre><code class="language-swift">fun MainViewController(): UIViewController {
  val appGraph = createGraph&lt;AppGraph&gt;()
  return ComposeUIViewController {
    App(appGraph = appGraph)
  }
}</code></pre>
<p><code>App</code> is the same <code>@Composable</code> you call from <code>MainActivity</code> on Android. From this point on, every screen you build runs on both platforms.</p>
<p>The reason initialization is split is that <code>Purchases.configure</code> reaches into the platform billing client immediately. On Android it asks <code>BillingClient</code> to open a connection. On iOS it registers a StoreKit transaction listener. Both need to happen before any Compose composition starts, which is why you do not configure inside <code>commonMain</code>.</p>
<h2><strong>Step 3: Check entitlements from common code</strong></h2>
<p>Once <code>Purchases</code> is configured, you can ask “is the current user entitled to premium?” from anywhere in <code>commonMain</code>. The KMP SDK exposes coroutine friendly suspend variants of every callback API. The one you want here is <code>awaitCustomerInfo()</code>:</p>
<pre><code class="language-kotlin">suspend fun isPremium(): Boolean {
  val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
  return customerInfo.entitlements[&quot;premium&quot;]?.isActive == true
}</code></pre>
<p><code>CustomerInfo.entitlements</code> is a map keyed by the entitlement identifier you set up in the dashboard. The value is an <code>EntitlementInfo</code> with an <code>isActive</code> flag, an <code>expirationDate</code>, a <code>willRenew</code> boolean, and a <code>store</code> enum that tells you which platform the underlying purchase came from. For access decisions, only <code>isActive</code> matters. The entitlement is active whether the user subscribed through the App Store, Google Play, Stripe, or a promotional grant from your support team.</p>
<p>Most apps wrap this in a repository so the rest of the app can collect a <code>Flow</code> and forget about callbacks. The cat-paywalls-kmp demo defines <code>PaywallsRepository</code> in <code>core/data</code>:</p>
<pre><code class="language-kotlin">interface PaywallsRepository {
  fun fetchOffering(): Flow&lt;Result&lt;Offering&gt;&gt;
  fun fetchCustomerInfo(): Flow&lt;Result&lt;CustomerInfo&gt;&gt;
  fun awaitPurchase(packageId: String): Flow&lt;Result&lt;StoreTransaction&gt;&gt;
}</code></pre>
<p>The <code>fetchCustomerInfo</code> implementation is a thin Flow wrapper around the SDK call, with <code>Dispatchers.IO</code> to keep network work off the main thread:</p>
<pre><code class="language-kotlin">override fun fetchCustomerInfo(): Flow&lt;Result&lt;CustomerInfo&gt;&gt; = flow {
  try {
    val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
    emit(Result.success(customerInfo))
  } catch (e: Exception) {
    emit(Result.failure(e))
  }
}.flowOn(Dispatchers.IO)</code></pre>
<p>A ViewModel collects this flow and exposes a <code>StateFlow&lt;CustomerInfo?&gt;</code>:</p>
<pre><code class="language-kotlin">class CatArticlesDetailViewModel(
  articleId: Long,
  articlesRepository: ArticlesRepository,
  paywallsRepository: PaywallsRepository,
) : ViewModel() {

  val customerInfo: StateFlow&lt;CustomerInfo?&gt; =
    paywallsRepository.fetchCustomerInfo()
      .map { it.getOrNull() }
      .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = null,
      )
}</code></pre>
<p>The Compose Multiplatform UI then reads it like any other state and decides whether to render the article body or fade it behind a paywall prompt:</p>
<pre><code class="language-kotlin">private const val ENTITLEMENT_PREMIUM = &quot;premium&quot;

@Composable
private fun CatArticlesDetailContent(
  article: Article,
  viewModel: CatArticlesDetailViewModel,
  navigateToPaywalls: () -&gt; Unit,
) {
  val customerInfo by viewModel.customerInfo.collectAsState()
  val isEntitled = customerInfo?.entitlements?.get(ENTITLEMENT_PREMIUM)?.isActive == true

  DetailsContent(
    article = article,
    onJoinClicked = navigateToPaywalls,
    isEntitled = isEntitled,
  )
}</code></pre>
<p><code>DetailsContent</code> applies a <code>fadingEdge</code> modifier when <code>isEntitled</code> is false. Subscribed users see the article through to the end. Free users see the first few paragraphs fade into a “Join Now” CTA. The same composable runs on both iOS and Android.</p>
<h2><strong>Step 4: Run a purchase from </strong><code><strong>commonMain</strong></code></h2>
<p>Triggering a purchase is two suspend calls: fetch the current offering, then call <code>awaitPurchase</code> with one of its packages. The SDK takes care of opening Google Play’s billing dialog or StoreKit’s purchase sheet, validating the receipt with the store, and posting it to RevenueCat for tracking.</p>
<p>The <code>awaitPurchase</code> helper from <code>purchases-kmp-core</code> accepts a <code>Package</code>:</p>
<pre><code class="language-kotlin">suspend fun purchaseMonthly() {
  val offerings = Purchases.sharedInstance.awaitOfferings()
  val current = offerings.current ?: error(&quot;No current offering configured&quot;)

  val monthly = current.monthly
    ?: current.availablePackages.first()

  val transaction = Purchases.sharedInstance.awaitPurchase(monthly)

  \/\/ The SDK has already verified the receipt and updated CustomerInfo.
  \/\/ Your existing customerInfo flow will emit the new state on its own.
}</code></pre>
<p><code>Offering</code> exposes convenience accessors for common cadences (<code>monthly</code>, <code>annual</code>, <code>lifetime</code>) and a generic <code>availablePackages: List&lt;Package&gt;</code> if you want to build a custom plan picker. The cat-paywalls-kmp demo wraps this in <code>PaywallsRepository.awaitPurchase(packageId)</code>:</p>
<pre><code class="language-kotlin">override fun awaitPurchase(packageId: String): Flow&lt;Result&lt;StoreTransaction&gt;&gt; = flow {
  try {
    val offerings = Purchases.sharedInstance.awaitOfferings()
    val pkg = offerings.current?.availablePackages?.find { it.identifier == packageId }
      ?: error(&quot;Package not found: $packageId&quot;)

    val transaction = Purchases.sharedInstance.awaitPurchase(pkg)
    emit(Result.success(transaction))
  } catch (e: Exception) {
    emit(Result.failure(e))
  }
}.flowOn(Dispatchers.IO)</code></pre>
<p>Two things to know about the return value. First, the <code>StoreTransaction</code> is informational only. The receipt has already been validated server side by the time <code>awaitPurchase</code> resumes, and the user’s <code>CustomerInfo</code> has been updated on RevenueCat’s backend. Second, the next call to <code>awaitCustomerInfo()</code> will reflect the new entitlement, which means any UI bound to your <code>customerInfo</code> flow recomposes automatically. You do not need to manually invalidate state.</p>
<p>If the user cancels the dialog, the SDK throws a <code>PurchasesException</code> whose <code>code</code> is <code>PurchaseCancelled</code>. Catch it and treat it as a no op rather than an error.</p>
<h2><strong>Step 5: Drop in a server driven paywall</strong></h2>
<p>You could build the package picker yourself, but RevenueCat’s Paywall Editor lets you design the entire screen in the dashboard and update it without shipping a new build. The <code>purchases-kmp-ui</code> artifact includes a <code>Paywall</code> composable that renders the configured paywall on both Android and iOS.</p>
<p>You design the paywall in the dashboard’s visual editor:</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/269625c136fa69c5c025c3bd092201392b6ffb6f-1024x735.gif" alt=""/></figure>
<p>In your KMP code, the entire paywall screen is one <code>Paywall</code> call. The cat-paywalls-kmp demo lives in <code>feature/paywalls/CatCustomPaywalls.kt</code>:</p>
<pre><code class="language-kotlin">@Composable
fun CatCustomPaywalls() {
  val composeNavigator = currentComposeNavigator

  Box(
    modifier = Modifier
      .fillMaxSize()
      .background(Color.White),
  ) {
    Paywall(
      options = PaywallOptions(
        dismissRequest = { composeNavigator.navigateUp() },
      ),
    )
  }
}</code></pre>
<p><code>PaywallOptions</code> is where you pass callbacks for dismissal, purchase completion, and restore. With no offering passed, the component renders the <strong>current offering</strong> assigned to the user in the dashboard. If you are running an A/B test, RevenueCat picks the variant for this user automatically and attributes any conversion to the right experiment arm.</p>
<p>This is the part that pays for itself. The entire visual design of the paywall, including which packages to show, how to highlight the recommended plan, what copy to use for the trial CTA, and which offering to display, is configurable from the dashboard. You can tweak headline copy or swap a one screen layout for a feature comparison layout in the morning, watch conversion metrics in the afternoon, and revert by clicking a button if the new variant underperforms. None of this requires a Play Store or App Store review cycle.</p>
<h2><strong>Putting it all together: the architecture</strong></h2>
<p>Here is how all the pieces line up in a finished KMP project. In code, the cat-paywalls-kmp demo organizes responsibilities by module:</p>
<ul>
<li><code><strong>composeApp</strong></code> is the only module with platform specific code. Its <code>androidMain</code> configures <code>Purchases</code> from the <code>Application</code>, its <code>iosMain</code> exposes a <code>MainViewController()</code> to SwiftUI, and its <code>commonMain</code> holds <code>App.kt</code> plus a Navigation Compose <code>NavHost</code>.</li>
<li><code><strong>feature/*</strong></code> modules are screens (home, article, paywalls, account, subscriptions). They depend on <code>core/*</code> and contain Compose Multiplatform UI plus ViewModels.</li>
<li><code><strong>core/data</strong></code> owns <code>PaywallsRepository</code>, the only place that calls into <code>Purchases.sharedInstance</code>. Everything else reads <code>Flow&lt;Offering&gt;</code> and <code>Flow&lt;CustomerInfo&gt;</code> from this repository.</li>
<li><code><strong>core/model</strong></code>, <code><strong>core/network</strong></code>, <code><strong>core/designsystem</strong></code>, <code><strong>core/navigation</strong></code> hold the data classes, Ktor client, theme, and navigation graph respectively.</li>
</ul>
<p>The final UX you ship looks like this. Subscribed users see the full article and a subscription management screen sourced from the same <code>CustomerInfo</code> object:</p>
<p>The same <code>SubscriptionManagementScreen</code> reads <code>customerInfo.activeSubscriptions</code>, <code>customerInfo.entitlements[&quot;premium&quot;]?.isActive</code>, and <code>customerInfo.originalAppUserId</code> to render its content, and runs without modification on both platforms. There is no <code>if (Build.VERSION...)</code> and no <code>#if os(iOS)</code>. The code lives once, in <code>commonMain</code>, and the SDK does the platform translation underneath.</p>
<p>If you want to inspect the full source as a reference, every file shown in this article is in <a href="https://github.com/RevenueCat/cat-paywalls-kmp">cat-paywalls-kmp</a>. The minimum surface to get a working integration is the four steps above plus the dashboard setup. Everything else is product specific UI.</p>
<h2><strong>Conclusion</strong></h2>
<p>In this article, you’ve configured the RevenueCat KMP SDK in a Compose Multiplatform project, initialized <code>Purchases</code> on Android and iOS, gated a premium screen with <code>CustomerInfo.entitlements</code>, run a real purchase through <code>awaitPurchase</code>, and rendered a server driven paywall with the <code>Paywall</code> composable from <code>purchases-kmp-ui</code>. Every piece of subscription logic except platform initialization lives in <code>commonMain</code>, which means your iOS and Android apps stay in lockstep without any code duplication.</p>
<p>The thing worth internalizing is what the SDK takes off your plate. You no longer think in terms of purchase tokens versus signed transactions, two notification pipelines, or two receipt verification servers. You think in terms of offerings, packages, entitlements, and <code>CustomerInfo</code>, and the SDK collapses both stores into those four concepts. That same abstraction is what makes the visual Paywall Editor possible, since it is far easier to ship one server driven UI when there is only one shared state model underneath.</p>
<p>Whether you are porting an existing Android subscription app to iOS, starting a new product on KMP from scratch, or experimenting with paywall variants without burning a release cycle, this setup gives you the smallest surface area you can ship a cross platform subscription app on. The remaining engineering time goes back into the parts of your app that actually differentiate your product.</p>
<p>As always, happy coding!</p>
<p>— <a href="https://github.com/skydoves/">Jaewoong</a> (skydoves)</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[How a switch from hard paywall to freemium led to a 75% LTV lift and a 50% conversion drop]]></title>
      <link>https://www.revenuecat.com/blog/growth/hard-paywall-vs-freemium</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/hard-paywall-vs-freemium</guid>
      <pubDate>Wed, 29 Apr 2026 16:06:13 GMT</pubDate>
      <dc:creator><![CDATA[David Barnard]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Moving from a hard paywall to freemium is like switching from checkers to chess]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/16274b447fa44ad9a70485b46989fb2c4bd48e6d-1600x800.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p>For the vast majority of subscription apps, the most reliable path to profitability is simple: lock your core value behind a <a href="https://www.revenuecat.com/blog/growth/hard-paywall-vs-soft-paywall/">hard paywall</a>.</p>
<p>The data backs this up. According to <a href="https://www.revenuecat.com/state-of-subscription-apps/">our State of Subscription Apps report</a>, hard paywalls convert downloads to paid at a median of 10.7% — five times better than the 2.1% median for freemium apps. The floor for hard paywalls (4.2%) is actually double the median for freemium.</p>
<p>“If you’re a bootstrapped startup or you’re operating with limited outside capital, it’s a much more reliable and low-risk way of growing your business,” explains growth advisor Phil Carter in <a href="https://www.revenuecat.com/blog/growth/phil-carter-elemental-growth-sub-club-podcast-2026/">a recent episode of the Sub Club podcast</a>.</p>
<p>But what if you want to build a billion-dollar company?</p>
<p>“There are a lot of examples of apps like Spotify, Duolingo, Strava that have done that through freemium,” Phil says. “You’re just going to attract a much larger user base at the top of the funnel if you have a free version of your product.”</p>
<p><a href="https://fast.wistia.com/medias/oy30z431st">Watch on Wistia</a></p>
<p>The transition from a hard paywall to freemium is the highest-ceiling growth lever a mature app can pull. It’s also the most dangerous. To illustrate the stakes, Phil shared the story of his biggest win — and his biggest failure — from the past year. Both involved the exact same strategy.</p>
<h2>The win: a 75% increase in LTV</h2>
<p>Phil’s biggest win came from helping an established subscription app move away from a traditional hard paywall. But rather than simply opening up the app and hoping users would eventually subscribe, his team implemented a “<a href="https://www.revenuecat.com/blog/growth/paywall-redesigns-case-studies/">multistep paywall</a>.”</p>
<p>The strategy reframed the value proposition. Instead of saying, <em>You have to pay for this product now</em>, the new onboarding flow communicated a different message: <em>This product is free and will always be free, but we want you to try the best version of it for </em><em><a href="https://www.revenuecat.com/blog/growth/7-day-trial-subscription-app/">seven days</a></em><em>. After that, we’d love to have you continue paying to get maximum value.</em></p>
<p>The results were staggering.</p>
<p>“We saw a 75% increase in LTV per user through the implementation of this multistep paywall along with some other <a href="https://www.revenuecat.com/blog/growth/offering-customization-examples-targeting/">pricing and packaging optimizations</a>,” Phil says.</p>
<p>By removing the hard gate, the app widened its top-of-funnel significantly. More users experienced the core product, organic acquisition accelerated, and the multistep paywall successfully captured the revenue upside.</p>
<p>“It pretty fundamentally altered the full potential size of this business,” Phil notes.</p>
<figure><img src="https://cdn.sanity.io/images/c3qnx9b0/production/2f4f561d57f0d4c09a4432db0cadd1da0df89165-1516x917.png" alt=""/></figure>
<h2>The fail: a 50% drop in conversion</h2>
<p>The success of the multistep paywall might suggest that every scaling app should immediately drop its hard paywall. But Phil’s biggest failure of the year serves as a stark warning against treating freemium as a silver bullet.</p>
<p>With a different client, Phil’s team attempted a similar shift to freemium. The results were disastrous.</p>
<p>“The initial results didn’t work at all,” he admits. “We saw more than a 50% reduction in subscriber conversion. We very quickly pulled off of it after a couple of weeks.”</p>
<p>Why did the exact same strategic move produce such radically different outcomes?</p>
<p>It comes down to <a href="https://www.revenuecat.com/blog/growth/product-market-fit-subscription-apps/">product-market fit</a> and the inherent complexity of <a href="https://www.revenuecat.com/blog/growth/how-to-turn-freemium-users-into-loyal-subscribers/">managing a freemium model</a>. When an app relies on a hard paywall, it forces a binary decision before the user has fully experienced the product. The conversion is driven by the promise of value, the quality of the marketing, and the friction of the paywall itself.</p>
<p>When you remove that friction, you’re entirely reliant on the product’s ability to demonstrate ongoing, undeniable value. If the free experience is too generous, users have no incentive to upgrade. If it’s too restrictive, they churn before forming a habit.</p>
<p>According to <a href="https://www.revenuecat.com/state-of-subscription-apps/">State of Subscription Apps 2026</a>, 55% of all 3-day trial cancellations happen on Day 0 — meaning the battle for the subscriber is won or lost in the first session.</p>
<p>As Phil puts it, moving from a hard paywall to freemium is “like moving from playing checkers to playing chess because it requires a lot more sophistication.”</p>
<h2>When should you make the switch?</h2>
<p>If you’re considering dropping your hard paywall, the data and Phil’s experience suggest a clear framework for the decision:</p>
<h3><strong>1. Stay with a hard paywall if:</strong></h3>
<ul>
<li>You’re bootstrapped or highly capital-constrained</li>
<li>You need immediate cash flow to fund paid acquisition</li>
<li>Your product solves an acute, immediate problem rather than building a long-term habit</li>
<li>You don’t have the product analytics infrastructure to rigorously test feature gating</li>
</ul>
<h3><strong>2. Consider testing freemium if:</strong></h3>
<ul>
<li>You’ve achieved strong product-market fit and high retention among your core users</li>
<li>You’ve got the runway to absorb a temporary dip in conversion rates while you optimize the model</li>
<li>Your product benefits from network effects or user-generated content (like <a href="https://www.revenuecat.com/blog/growth/cem-kansu-duolingo-sub-club-podcast-2026/">Duolingo’s massive free experience</a>)</li>
<li>You’ve hit a growth ceiling with paid acquisition and need to unlock organic, word-of-mouth growth at scale</li>
</ul>
<p><a href="https://fast.wistia.com/medias/ksqoeu5aw4">Watch on Wistia</a></p>
<p>Freemium isn’t a pricing strategy; it’s a product strategy. If you treat it simply as a different way to display your paywall, you risk halving your conversion rate. But if you treat it as a fundamental shift in how you deliver value, it might just be the move that turns your app into a billion-dollar business.</p>]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[The counterintuitive freemium strategy that scaled Opal to 1M daily active users]]></title>
      <link>https://www.revenuecat.com/blog/growth/kenneth-schlenker-sub-club-podcast-2026</link>
      <guid isPermaLink="false">https://www.revenuecat.com/blog/growth/kenneth-schlenker-sub-club-podcast-2026</guid>
      <pubDate>Wed, 29 Apr 2026 12:58:49 GMT</pubDate>
      <dc:creator><![CDATA[David Barnard]]></dc:creator>
      <category><![CDATA[Growth]]></category>
      <description><![CDATA[Kenneth Schlenker dropped Opal’s paid conversion rate from 20% to 9% — and it resulted in an explosion of daily active users and compounding revenue.]]></description>
      <enclosure url="https://cdn.sanity.io/images/c3qnx9b0/production/c430dc65cd256a7340f794d2de7ae025982df665-1600x840.png" length="0" type="image/*"/>
      <content:encoded><![CDATA[<p><a href="https://www.youtube.com/watch?v=tJnJflSXaE4">Watch on YouTube</a></p>
<h2>Why dropping conversion to 9% was the right move</h2>
<p>When Opal, the popular screen time and focus app, hit $5 million in annual recurring revenue (ARR), they were scaling efficiently with a hard paywall. The business model was working, but user growth was plateauing.</p>
<p>To build a product capable of reaching a billion users, CEO Kenneth Schlenker made a terrifying decision: transition to a true freemium model. The immediate result was a massive drop in their download-to-paid conversion rate, plummeting from 20% down to 9%.</p>
<p>But that drop was exactly what the company needed. By giving away the core product for free, Opal unlocked entirely new segments—specifically high school and college students, who now make up two-thirds of their daily active users (DAUs). These free users became the app’s marketing engine. The initial hit to conversion was eclipsed by an explosion in organic growth, pushing Opal past 1 million DAUs and $10 million in ARR.</p>
<p>“We’ve seen a decrease in our conversion to paid, but we’ve also seen as a consequence of that an explosion of our DAUs and revenue with it,” Schlenker explains. “It’s a short-term, scary drop, but what happens in the long-term is that it pays back tenfold.”</p>
<h2>The “would a free user recommend it?” test</h2>
<p>The success of a freemium model hinges on a single question: would a non-paying user recommend the app to a friend? If the answer is no, the paywall is too restrictive.</p>
<p>Schlenker argues that many apps fail at freemium because their free tier feels like a crippled trial rather than a complete experience. If users hit aggressive limits before they can extract real value, they won’t stick around, and they certainly won’t tell their friends.</p>
<p>To find the right balance, Opal experimented heavily with their “blocks” feature. They tested giving free users just one block, then two, then more. They eventually landed on three free blocks per day. This proved to be the sweet spot—enough functionality for free users to get a great experience and build a habit, while still leaving power users hungry enough to upgrade.</p>
<h2>You can’t “vibe code” a brand</h2>
<p>With the rise of AI coding tools, the narrative of the “billion-dollar one-person company” has gained traction. The idea is that a single developer can spin up a functional app in a weekend and scale it to massive revenue. Schlenker calls this a lie.</p>
<p>While AI is incredibly efficient at generating functional tools, it cannot generate emotional resonance. A screen time app is technically simple to build—Schlenker notes that people frequently launch Opal clones—but building a category-defining company requires more than just code.</p>
<p>“Teams create product soul. You can’t vibe code a brand,” Schlenker says. “I think that a recipe for failure in my mind is just to create something that has a function, which AI does really, really well… but you need more than that to be able to be successful.”</p>
<p>That “soul” is evident in Opal’s design choices, like the highly engineered, tactile interaction of cracking open a digital gemstone when a user hits a focus milestone. It’s an expensive, difficult feature to build that doesn’t directly move a metric on a spreadsheet, but it’s the exact kind of magical moment that builds brand affinity and long-term retention.</p>
<h2>Expanding beyond the App Store into schools</h2>
<p>Opal’s organic growth among students led to an unexpected B2B opportunity. A high school in Los Angeles reached out to the company because their students—who were already using Opal independently—suggested the app as a solution to the state’s impending phone ban.</p>
<p>This organically birthed “Opal for Schools,” a specialized deployment that blocks distracting apps while students are on campus, but allows access to educational tools and emergency contacts. Crucially, it’s not just a “bell-to-bell” restriction. Students keep the app after school, using it to manage their own study time and sleep schedules.</p>
<p>By partnering with schools, Opal is essentially building the next generation of professional users while solving an immediate crisis for educators.</p>
<p>In <a href="https://www.youtube.com/watch?v=tJnJflSXaE4">the full episode</a>, Kenneth also discusses why retention is the only real moat for consumer apps, how they turned a viral user video into their most successful ad, and why every AI feature should be evaluated by whether it makes the user win.</p>
<h2>Guest links:</h2>
<ul>
<li><a href="https://www.linkedin.com/in/kennethschlenker/">Kenneth Schlenker on LinkedIn</a></li>
<li><a href="https://twitter.com/kennethschlenk">Kenneth Schlenker on X</a></li>
<li><a href="https://www.opal.so/">Opal</a></li>
</ul>]]></content:encoded>
    </item>
  </channel>
</rss>