DEV Community

Steve Dornan
Steve Dornan

Posted on

Stripe Web Checkout in a Mobile App: The Post-Epic External Payments Pattern (.NET Implementation)

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      │
└───────────────────┘        └───────────────────────┘        └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

Three rules make this compliant and reliable:

  1. System browser, not a WebView. The external-link model both stores permit means leaving the app. A WebView checkout is how you fail review.
  2. 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.)
  3. 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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

Putting userId in the subscription metadata on top of ClientReferenceId is 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_status directly, 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.