Part of a short series on shipping subscriptions in .NET MAUI — see also the full RevenueCat setup guide and the AMM0000 build-error fix.
Since the Epic v. Apple 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: how do I actually build the web-payment path without getting rejected or double-charging users?
Here's the architecture that works, with a concrete ASP.NET Core + .NET MAUI implementation. The pattern is identical in any stack.
The architecture in one diagram
┌─ 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 │
└───────────────────┘ └───────────────────────┘ └──────────────────┘
Three rules make this compliant and reliable:
- System browser, not a WebView. The external-link model both stores permit means leaving the app. A WebView checkout is how you fail review.
- Entitlement is granted server-side by webhook — never by the client claiming "I paid." This also means a purchase made on your website, with no app involved, unlocks the app. (That "Reader-style" flow has always been allowed.)
- The app discovers the entitlement by asking your server, merged with any store subscription so your feature gates don't care where the money came from.
Server: creating the checkout link
The critical detail is ClientReferenceId — it ties the anonymous Stripe session back to your authenticated user when the webhook fires:
var session = await new SessionService().CreateAsync(new SessionCreateOptions
{
Mode = "subscription",
LineItems = [ new() { Price = priceId, Quantity = 1 } ],
ClientReferenceId = userId.ToString(), // ← the linchpin
SuccessUrl = _options.CheckoutSuccessUrl,
CancelUrl = _options.CheckoutCancelUrl,
SubscriptionData = new SessionSubscriptionDataOptions
{
Metadata = new() { ["userId"] = userId.ToString(), ["tier"] = tierId }
}
});
return session.Url; // app opens this in the system browser
Putting userId in the subscription's metadata too means renewal and cancellation webhooks — which arrive months later with no session context — can still find your user.
Server: the webhook
[HttpPost("webhook")]
public async Task<IActionResult> Webhook()
{
var json = await new StreamReader(Request.Body).ReadToEndAsync();
var stripeEvent = EventUtility.ConstructEvent(
json, Request.Headers["Stripe-Signature"], _webhookSecret); // verify!
switch (stripeEvent.Type)
{
case "checkout.session.completed": // initial purchase
case "customer.subscription.updated": // renewals, plan changes
case "customer.subscription.deleted": // cancellations
await _billing.HandleAsync(stripeEvent);
break;
}
return Ok();
}
One Stripe-API gotcha that costs people an hour: in current API versions, current_period_end lives on the subscription item, not the subscription:
var periodEnd = stripeSub.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd;
Client: merged entitlement state
The app's subscription service checks the store SDK first, then your API:
public async Task<SubscriptionState> GetStateAsync(bool forceRefresh = false)
{
// 1. Store entitlement (RevenueCat / StoreKit / Play Billing)
if (storeEntitlementActive) return storeState;
// 2. Web/trial subscription from your API
var web = await _billingApi.GetStatusAsync(); // JWT-authenticated
if (web?.HasActiveSubscription == true)
return new SubscriptionState { IsPremium = true, Source = "web", ... };
return SubscriptionState.None;
}
And because the user returns from the browser with no signal, give them a refresh affordance on the paywall: "I completed my purchase — refresh." One tap, GetStateAsync(forceRefresh: true), entitlement appears.
The policy question everyone asks
"Will Apple/Google reject this?" The honest answer: rules differ by market and have changed more than once. Two design decisions de-risk it:
- Serve the web tiers from your API. If a market doesn't permit external links, return an empty array there — the UI section never renders, no app update needed.
- Web purchases work without the app ever linking out. Worst case, you market the web plan on your website and the app just honors it. That path has always been allowed.
Local testing
stripe listen --forward-to http://localhost:5199/api/billing/webhook
stripe trigger checkout.session.completed
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 *MidasKit*, the .NET MAUI subscription-app boilerplate. Skip the integration month.
Top comments (1)
Putting
userIdin the subscription metadata on top ofClientReferenceIdis the part most people skip, and they find out at the first renewal, when the event shows up months later with no session context and nothing to join on.One thing worth handling in step 6: the app can ask your server for entitlement before Stripe's webhook has landed, so the user sees "not subscribed" a few seconds after paying. That is the exact moment they force-quit, pay again, or open a dispute, and webhook delivery being fast doesn't help, because it isn't ordered against the user tapping back into the app.
Cheapest fix I know is to pass the session id back on return and have the server retrieve the Checkout Session from Stripe and read
payment_statusdirectly, rather than waiting on delivery. Fulfill from both paths and make fulfillment idempotent on the session id, so whichever arrives first wins and the second is a no-op. The webhook still has to exist on its own, since a fair number of people pay in the browser and never come back to the app at all.