<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Steve Dornan</title>
    <description>The latest articles on DEV Community by Steve Dornan (@steve_dornan_9d4e57773ec7).</description>
    <link>https://dev.to/steve_dornan_9d4e57773ec7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4040203%2F2751d7ba-ef8c-4448-9ada-6b10c955f15a.png</url>
      <title>DEV Community: Steve Dornan</title>
      <link>https://dev.to/steve_dornan_9d4e57773ec7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/steve_dornan_9d4e57773ec7"/>
    <language>en</language>
    <item>
      <title>Stripe Web Checkout in a Mobile App: The Post-Epic External Payments Pattern (.NET Implementation)</title>
      <dc:creator>Steve Dornan</dc:creator>
      <pubDate>Wed, 05 Aug 2026 13:53:02 +0000</pubDate>
      <link>https://dev.to/steve_dornan_9d4e57773ec7/stripe-web-checkout-in-a-mobile-app-the-post-epic-external-payments-pattern-net-implementation-2lng</link>
      <guid>https://dev.to/steve_dornan_9d4e57773ec7/stripe-web-checkout-in-a-mobile-app-the-post-epic-external-payments-pattern-net-implementation-2lng</guid>
      <description>&lt;p&gt;&lt;em&gt;Part of a short series on shipping subscriptions in .NET MAUI — see also the &lt;a href="https://dev.to/steve_dornan_9d4e57773ec7/net-maui-in-app-subscriptions-with-revenuecat-the-2026-setup-that-actually-compiles-53f7"&gt;full RevenueCat setup guide&lt;/a&gt; and the &lt;a href="https://dev.to/steve_dornan_9d4e57773ec7/fix-amm0000-namespace-used-in-multiple-modules-when-adding-google-play-billing-to-net-maui-1obm"&gt;AMM0000 build-error fix&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Since the &lt;em&gt;Epic v. Apple&lt;/em&gt; injunction took effect, US App Store apps can link out to external purchase flows — and keep the store's 15–30% cut. Google has its own external-offers programs. Every subscription-app developer is now asking the same question: &lt;strong&gt;how do I actually build the web-payment path without getting rejected or double-charging users?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the architecture that works, with a concrete ASP.NET Core + .NET MAUI implementation. The pattern is identical in any stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture in one diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─ App ─────────────┐        ┌─ Your API ────────────┐        ┌─ Stripe ─────────┐
│ Paywall           │        │                       │        │                  │
│  "Subscribe on    │──1──→  │ POST /billing/        │──2──→  │ Checkout Session │
│   the web $9.99"  │        │      checkout-link    │        │ (hosted page)    │
│                   │ ←──3── │   returns session URL │        │                  │
│ opens SYSTEM      │        │                       │        │ user pays here   │
│ browser ──────────┼────────┼───────────────────────┼──4──→  │                  │
│                   │        │ POST /billing/webhook │ ←──5── │ checkout.session.│
│ "I paid → refresh"│──6──→  │ grants entitlement    │        │   completed      │
└───────────────────┘        └───────────────────────┘        └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules make this compliant and reliable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;System browser, not a WebView.&lt;/strong&gt; The external-link model both stores permit means leaving the app. A WebView checkout is how you fail review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entitlement is granted server-side by webhook&lt;/strong&gt; — never by the client claiming "I paid." This also means a purchase made on your &lt;em&gt;website&lt;/em&gt;, with no app involved, unlocks the app. (That "Reader-style" flow has always been allowed.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The app discovers the entitlement by asking your server&lt;/strong&gt;, merged with any store subscription so your feature gates don't care where the money came from.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Server: creating the checkout link
&lt;/h2&gt;

&lt;p&gt;The critical detail is &lt;code&gt;ClientReferenceId&lt;/code&gt; — it ties the anonymous Stripe session back to your authenticated user when the webhook fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SessionService&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SessionCreateOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"subscription"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;LineItems&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Price&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;priceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;ClientReferenceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;          &lt;span class="c1"&gt;// ← the linchpin&lt;/span&gt;
    &lt;span class="n"&gt;SuccessUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckoutSuccessUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancelUrl&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckoutCancelUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SubscriptionData&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SessionSubscriptionDataOptions&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tier"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tierId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// app opens this in the system browser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting &lt;code&gt;userId&lt;/code&gt; in the &lt;em&gt;subscription's&lt;/em&gt; metadata too means renewal and cancellation webhooks — which arrive months later with no session context — can still find your user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server: the webhook
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"webhook"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Webhook&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StreamReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ReadToEndAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;stripeEvent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EventUtility&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConstructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Stripe-Signature"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;_webhookSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// verify!&lt;/span&gt;

    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stripeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"checkout.session.completed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;// initial purchase&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"customer.subscription.updated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;// renewals, plan changes&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"customer.subscription.deleted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;// cancellations&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_billing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stripeEvent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One Stripe-API gotcha that costs people an hour: in current API versions, &lt;code&gt;current_period_end&lt;/code&gt; lives on the &lt;strong&gt;subscription item&lt;/strong&gt;, not the subscription:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;periodEnd&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripeSub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Items&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="n"&gt;CurrentPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Client: merged entitlement state
&lt;/h2&gt;

&lt;p&gt;The app's subscription service checks the store SDK first, then your API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SubscriptionState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetStateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;forceRefresh&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Store entitlement (RevenueCat / StoreKit / Play Billing)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;storeEntitlementActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;storeState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Web/trial subscription from your API&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;web&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_billingApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetStatusAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// JWT-authenticated&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;web&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;HasActiveSubscription&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SubscriptionState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;IsPremium&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"web"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SubscriptionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because the user returns from the browser with no signal, give them a refresh affordance on the paywall: &lt;em&gt;"I completed my purchase — refresh."&lt;/em&gt; One tap, &lt;code&gt;GetStateAsync(forceRefresh: true)&lt;/code&gt;, entitlement appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy question everyone asks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Will Apple/Google reject this?"&lt;/strong&gt; The honest answer: rules differ by market and have changed more than once. Two design decisions de-risk it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serve the web tiers from your API.&lt;/strong&gt; If a market doesn't permit external links, return an empty array there — the UI section never renders, no app update needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web purchases work without the app ever linking out.&lt;/strong&gt; Worst case, you market the web plan on your website and the app just honors it. That path has always been allowed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Local testing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe listen &lt;span class="nt"&gt;--forward-to&lt;/span&gt; http://localhost:5199/api/billing/webhook
stripe trigger checkout.session.completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;This entire flow — plus RevenueCat store billing, OTP auth with rotating refresh tokens, server-issued trials, and a finished paywall — ships wired-and-compiling in *&lt;/em&gt;&lt;a href="https://midaskit.com" rel="noopener noreferrer"&gt;MidasKit&lt;/a&gt;*&lt;em&gt;, the .NET MAUI subscription-app boilerplate. &lt;a href="https://midaskit.com" rel="noopener noreferrer"&gt;Skip the integration month.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>stripe</category>
      <category>mobile</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Fix: AMM0000 "Namespace used in multiple modules" When Adding Google Play Billing to .NET MAUI</title>
      <dc:creator>Steve Dornan</dc:creator>
      <pubDate>Fri, 24 Jul 2026 13:01:17 +0000</pubDate>
      <link>https://dev.to/steve_dornan_9d4e57773ec7/fix-amm0000-namespace-used-in-multiple-modules-when-adding-google-play-billing-to-net-maui-1obm</link>
      <guid>https://dev.to/steve_dornan_9d4e57773ec7/fix-amm0000-namespace-used-in-multiple-modules-when-adding-google-play-billing-to-net-maui-1obm</guid>
      <description>&lt;p&gt;&lt;em&gt;Setting up subscriptions from scratch? Start with the &lt;a href="https://dev.to/steve_dornan_9d4e57773ec7/net-maui-in-app-subscriptions-with-revenuecat-the-2026-setup-that-actually-compiles-53f7"&gt;full .NET MAUI + RevenueCat setup guide&lt;/a&gt; — then come back here the moment the build breaks with &lt;code&gt;AMM0000&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You added an in-app billing package to your .NET MAUI app (RevenueCat wrapper, Plugin.InAppBilling, or anything pulling Google Play Billing), hit build, and got a wall of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;java.exe error AMM0000: ...\AndroidManifest.xml Warning:
    Namespace 'com.android.billingclient' is used in multiple modules and/or libraries:
    AndroidManifest.xml, AndroidManifest.xml. Please ensure that all modules and
    libraries have a unique namespace.
java.exe error AMM0000:     Namespace 'com.google.android.gms.base' is used in multiple modules...
java.exe error AMM0000:     Namespace 'com.google.firebase.encoders.json' is used in multiple modules...
Build FAILED.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dozens of repeated "namespace" errors pointing at &lt;code&gt;billing.aar&lt;/code&gt;, &lt;code&gt;play-services-base.aar&lt;/code&gt;, and friends. Cleaning &lt;code&gt;bin&lt;/code&gt;/&lt;code&gt;obj&lt;/code&gt; doesn't help. Downgrading packages doesn't help.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Those namespace lines are not your problem.&lt;/strong&gt; They're &lt;em&gt;warnings&lt;/em&gt; from the Android manifest merger that .NET for Android surfaces with an &lt;code&gt;error AMM0000&lt;/code&gt; prefix, which makes everyone chase the wrong thing (I did too).&lt;/p&gt;

&lt;p&gt;Scroll to the &lt;strong&gt;bottom&lt;/strong&gt; of the error output. The actual build-killer is almost always this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uses-sdk:minSdkVersion 21 cannot be smaller than version 23 declared in library
[...androidx.lifecycle...]
    Suggestion: use a compatible library with a minSdk of at most 21,
        or increase this project's minSdk version to at least 23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The .NET MAUI project template sets Android minimum to &lt;strong&gt;API 21&lt;/strong&gt;. The Play Billing dependency chain (via androidx.lifecycle and GMS libraries) requires &lt;strong&gt;API 23+&lt;/strong&gt;. The merger fails on that one real error — and dumps every accumulated warning alongside it, burying the signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix (one line)
&lt;/h2&gt;

&lt;p&gt;In your &lt;code&gt;.csproj&lt;/code&gt;, raise the Android minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;SupportedOSPlatformVersion&lt;/span&gt;
    &lt;span class="na"&gt;Condition=&lt;/span&gt;&lt;span class="s"&gt;"$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    24.0
&lt;span class="nt"&gt;&amp;lt;/SupportedOSPlatformVersion&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rebuild. The minSdk error is gone, the namespace warnings stop being fatal, and the build passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build succeeded.
    0 Error(s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why 24 and not 23?
&lt;/h2&gt;

&lt;p&gt;23 satisfies today's constraint; 24 clears the next library in the chain that wants it and still covers ~97%+ of active Android devices. If your analytics say you need 23, use 23 — the point is anything ≥ the version named in &lt;em&gt;your&lt;/em&gt; error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that do NOT fix it (save yourself the afternoon)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;❌ Cleaning &lt;code&gt;bin&lt;/code&gt; and &lt;code&gt;obj&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;❌ &lt;code&gt;&amp;lt;AndroidManifestMerger&amp;gt;legacy&amp;lt;/AndroidManifestMerger&amp;gt;&lt;/code&gt; — the warnings persist and you lose modern merger features&lt;/li&gt;
&lt;li&gt;❌ &lt;code&gt;tools:overrideLibrary&lt;/code&gt; — works around the symptom, invites runtime crashes on old devices&lt;/li&gt;
&lt;li&gt;❌ Downgrading the billing package — the androidx floor moved ecosystem-wide&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pattern to remember
&lt;/h2&gt;

&lt;p&gt;When .NET for Android gives you a wall of &lt;code&gt;AMM0000&lt;/code&gt; output: &lt;strong&gt;ignore every line that says &lt;code&gt;Warning:&lt;/code&gt; and read the last block.&lt;/strong&gt; The merger reports exactly one fatal condition; everything else is decoration.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is one of a dozen integration landmines I defused while building *&lt;/em&gt;&lt;a href="https://midaskit.com" rel="noopener noreferrer"&gt;MidasKit&lt;/a&gt;** — a .NET MAUI subscription-app boilerplate with RevenueCat, Stripe web checkout, auth, and a finished paywall already wired and compiling. If you're adding billing to a MAUI app right now, &lt;a href="https://midaskit.com" rel="noopener noreferrer"&gt;it'll save you the rest of the week&lt;/a&gt;.*&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>maui</category>
      <category>android</category>
      <category>debugging</category>
    </item>
    <item>
      <title>.NET MAUI In-App Subscriptions with RevenueCat: The 2026 Setup That Actually Compiles</title>
      <dc:creator>Steve Dornan</dc:creator>
      <pubDate>Thu, 23 Jul 2026 14:45:48 +0000</pubDate>
      <link>https://dev.to/steve_dornan_9d4e57773ec7/net-maui-in-app-subscriptions-with-revenuecat-the-2026-setup-that-actually-compiles-53f7</link>
      <guid>https://dev.to/steve_dornan_9d4e57773ec7/net-maui-in-app-subscriptions-with-revenuecat-the-2026-setup-that-actually-compiles-53f7</guid>
      <description>&lt;p&gt;If you've searched "RevenueCat MAUI" you've already discovered the awkward truth: &lt;strong&gt;RevenueCat has no official .NET MAUI SDK&lt;/strong&gt;. Flutter, React Native, Unity, Cordova, KMP — all officially supported. MAUI? A community wrapper and a forum thread from 2023 asking when it's coming.&lt;/p&gt;

&lt;p&gt;Here's the good news: the community answer is actually solid, and in this guide we'll go from &lt;code&gt;File &amp;gt; New Project&lt;/code&gt; to a working subscription purchase on Android — including the two build errors that stop most people, and how to structure the code so your app never depends on any particular billing SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lay of the land
&lt;/h2&gt;

&lt;p&gt;Your realistic options for MAUI subscriptions in 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plugin.InAppBilling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Direct store billing. Works, but you get raw store APIs — receipt validation, cross-platform entitlement logic, and grace-period handling are all yours to build.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kebechet.Maui.RevenueCat.InAppBilling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Community wrapper around RevenueCat's native SDKs. Actively maintained (v7.x, updated mid-2026), unified C# API, and you inherit RevenueCat's server-side receipt validation, entitlements, and analytics.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Raw StoreKit 2 / Play Billing bindings&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You are now a billing infrastructure company.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We're using the Kebechet wrapper. RevenueCat's free tier covers you to $2,500/month tracked revenue, which is exactly the phase where you shouldn't be building billing infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Kebechet.Maui.RevenueCat.InAppBilling
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 — The two build errors everyone hits
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Error 1: &lt;code&gt;AMM0000&lt;/code&gt; manifest merger failure.&lt;/strong&gt; The moment you add the package, your Android build explodes with walls of &lt;em&gt;"Namespace 'com.android.billingclient' is used in multiple modules"&lt;/em&gt; warnings and, buried at the bottom, the actual killer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uses-sdk:minSdkVersion 21 cannot be smaller than version 23
declared in library [androidx.lifecycle...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MAUI template defaults to Android API 21. The Play Billing dependency chain requires more. The fix is one line in your &lt;code&gt;.csproj&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;SupportedOSPlatformVersion&lt;/span&gt;
    &lt;span class="na"&gt;Condition=&lt;/span&gt;&lt;span class="s"&gt;"$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    24.0
&lt;span class="nt"&gt;&amp;lt;/SupportedOSPlatformVersion&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(24 rather than 23 keeps you clear of the whole chain and covers 97%+ of active devices.) The namespace warnings are noise — once the minSdk error is gone, the build passes. I wrote a &lt;a href="https://dev.to/steve_dornan_9d4e57773ec7/fix-amm0000-namespace-used-in-multiple-modules-when-adding-google-play-billing-to-net-maui-1obm"&gt;separate deep-dive on this exact error&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error 2: &lt;code&gt;IHttpClientFactory&lt;/code&gt; not found&lt;/strong&gt; — not from this package, but you'll hit it the moment you wire your paywall to a backend. &lt;code&gt;dotnet add package Microsoft.Extensions.Http&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Initialize (in the right place)
&lt;/h2&gt;

&lt;p&gt;The SDK must initialize in &lt;code&gt;OnStart&lt;/code&gt;, not in &lt;code&gt;App&lt;/code&gt;'s constructor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Application&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ISubscriptionService&lt;/span&gt; &lt;span class="n"&gt;_subscriptions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;App&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ISubscriptionService&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;InitializeComponent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;_subscriptions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnStart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// wraps Purchases.Configure&lt;/span&gt;
        &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OnStart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice we're already hiding RevenueCat behind our own interface. That's the single most important decision in this article — more below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Don't marry the SDK: the &lt;code&gt;ISubscriptionService&lt;/code&gt; abstraction
&lt;/h2&gt;

&lt;p&gt;The wrapper's &lt;code&gt;IRevenueCatBilling&lt;/code&gt; interface is fine, but binding your ViewModels to it means every SDK change ripples through your app. Define the interface &lt;em&gt;your app&lt;/em&gt; actually needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ISubscriptionService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;LoginAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;LogoutAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SubscriptionState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetStateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;forceRefresh&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;IsPremiumAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StorePackage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetPackagesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;forceRefresh&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PurchaseResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;PurchaseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StorePackage&lt;/span&gt; &lt;span class="n"&gt;package&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RestoreAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the RevenueCat-backed implementation maps their DTOs to yours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StorePackage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetPackagesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;forceRefresh&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;offerings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_revenueCat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetOfferings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forceRefresh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;offerings&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SelectMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AvailablePackages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;StorePackage&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Identifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;PriceLocalized&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pricing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PriceLocalized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Period&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SubscriptionPeriod&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;NativePackage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;            &lt;span class="c1"&gt;// opaque handle for purchase time&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two huge wins fall out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A mock implementation for Windows.&lt;/strong&gt; MAUI development on Windows is fast — but there's no Play Store on your dev box. Register a &lt;code&gt;MockSubscriptionService&lt;/code&gt; for non-mobile targets and you can build and demo your entire paywall flow without an emulator or a store account:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if ANDROID || IOS
&lt;/span&gt;    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRevenueCatBilling&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ISubscriptionService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RevenueCatSubscriptionService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="cp"&gt;#else
&lt;/span&gt;    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ISubscriptionService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MockSubscriptionService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Web payments slot in invisibly.&lt;/strong&gt; When you later add Stripe checkout for web purchases (post-Epic external links), &lt;code&gt;GetStateAsync()&lt;/code&gt; merges both sources and your feature gates never change.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 5 — Identify the user
&lt;/h2&gt;

&lt;p&gt;Anonymous purchases are a support nightmare — the moment a user reinstalls or switches devices, "where's my subscription?" tickets begin. After your login flow succeeds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LoginAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// RevenueCat Login(appUserId)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now entitlements follow the account, not the device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Gate features
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RelayCommand&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;UsePremiumFeatureAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsPremiumAsync&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GoToPaywallAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// premium feature&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GetStateAsync&lt;/code&gt; checks the RevenueCat entitlement (cached ~5 min) and falls back to your server for web/trial subscriptions. One code path, every payment source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — RevenueCat dashboard wiring
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Project → Android app (package name) + iOS app (bundle id) → copy the &lt;strong&gt;public&lt;/strong&gt; API keys.&lt;/li&gt;
&lt;li&gt;Create the store products (Play Console / App Store Connect).&lt;/li&gt;
&lt;li&gt;RevenueCat: create an &lt;strong&gt;entitlement&lt;/strong&gt; (&lt;code&gt;premium&lt;/code&gt;), attach products, build the &lt;code&gt;default&lt;/code&gt; &lt;strong&gt;offering&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The offering is why this beats hardcoding SKUs: your paywall renders whatever the offering contains, so you can test pricing without shipping an app update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing on Android
&lt;/h2&gt;

&lt;p&gt;Upload one build to a Closed Testing track, add yourself as a license tester, and purchases on debug builds use test cards. No real money, real purchase flow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Or skip all of it
&lt;/h2&gt;

&lt;p&gt;Everything in this article — plus the auth system (OTP, JWT, rotating refresh tokens), the Stripe web-checkout path, server-issued free trials, and a finished paywall UI — is packaged as &lt;strong&gt;&lt;a href="https://midaskit.com" rel="noopener noreferrer"&gt;MidasKit&lt;/a&gt;&lt;/strong&gt;, a production-grade .NET MAUI subscription boilerplate. It compiles out of the box; you wire your keys and ship. &lt;em&gt;&lt;a href="https://midaskit.com" rel="noopener noreferrer"&gt;Get it here.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>maui</category>
      <category>subscriptions</category>
      <category>dotnet</category>
      <category>csharp</category>
    </item>
  </channel>
</rss>
