US | Payments | 2026-08-16

Stripe Subscriptions in Custom Mobile Apps

A subscription system that grants access on the client is a subscription system that will eventually give away your product for free.

Stripe Subscriptions in Custom Mobile Apps

Here is the bug that shows up in almost every first subscription build. The app opens Stripe Checkout, the user pays, Stripe redirects back to a success URL, and the app writes isPremium: true to the user record. It works in testing. It works for weeks. Then someone edits the deep link, or simply closes the browser tab before the redirect fires. Now you have either a free premium account or a paying customer with no access, and no reliable way to tell which.

The correct model is that the client never decides entitlement. Stripe decides, your backend records the decision, and the app reads it. Everything below follows from that one rule.

Entitlement lives in your database, written only by webhooks

Your app should have a single source of truth: a document or row keyed by user, holding stripeCustomerId, subscriptionStatus, priceId, currentPeriodEnd, and cancelAtPeriodEnd. The client reads it. The client never writes it. In Firestore, the security rule for that field is simply allow write: if false, so only the Admin SDK inside your Cloud Function can touch it.

The events that actually matter are fewer than the full webhook catalog suggests:

Rather than computing state yourself, mirror subscription.status directly. Stripe's values are trialing, active, past_due, canceled, unpaid, incomplete, incomplete_expired and paused. Decide once which of those grant access. A reasonable default: trialing and active grant full access, past_due grants access with a visible warning banner, everything else does not.

Verify the signature, and do it on the raw body

Your webhook endpoint is a public URL. Anyone who finds it can POST JSON that looks exactly like a Stripe event. Signature verification is what stops that.

The failure mode that wastes the most time here is framework body parsing. stripe.webhooks.constructEvent() hashes the exact bytes Stripe sent. If Express has already run express.json() and re-serialized the payload, key order and whitespace change and the signature fails with a confusing error. In Firebase Cloud Functions you want req.rawBody; in Express you want express.raw({type: 'application/json'}) mounted on that route only.

Two more details. The signing secret differs between your test and live endpoints, and between the Stripe CLI's local forwarding and your deployed endpoint, so keep them in separate config values. And Stripe's default tolerance rejects timestamps more than 300 seconds old, which means a badly skewed server clock will silently fail every webhook you receive.

Assume every event arrives twice

Stripe guarantees at-least-once delivery, not exactly-once. Network hiccups, a slow response from your function, or a retry after a 500 all produce duplicates. On top of that, Stripe retries failed webhook deliveries with exponential backoff for up to three days in live mode. An event you fumbled on Monday can land again on Wednesday.

The fix is cheap. Before processing, write the event ID to a collection keyed by that ID, using a create-only operation. If the write fails because the document exists, you have already handled it, so return 200 and stop. In Firestore that is a transaction that reads processed_events/{event.id} and aborts if present.

Two related habits. Return 200 fast: acknowledge, then queue slow work like sending an email, because Stripe treats a timeout as a failure and retries. And when your handler calls back into Stripe to create something, pass an Idempotency-Key so a retried handler does not create a second charge or a second customer.

Ordering is the subtler trap. Webhooks are not guaranteed to arrive in order, so a subscription.updated generated at 10:00:01 can land after one generated at 10:00:03. Guard against it by comparing the event's created timestamp against a stored lastEventAt on the user record and discarding anything older, or by re-fetching the subscription from the API and writing that instead of trusting the payload.

Plan changes, trials, and failed payments

Proration. When a customer moves from a $9/month plan to $29/month mid-cycle, Stripe's default proration_behavior: 'create_prorations' adds line items crediting unused time and charging the new rate, but it does not bill immediately. Those line items sit until the next invoice. If you want the customer charged for the upgrade now, you need proration_behavior: 'always_invoice'. Downgrades are usually better handled with 'none' plus a scheduled change at period end, which avoids issuing credits to a customer who may churn anyway.

Trials. Set trial_period_days on the subscription. The status is trialing, and customer.subscription.trial_will_end fires three days before expiry, which is your prompt to email or notify in-app. Decide deliberately whether you require a payment method up front: collecting it lifts conversion at trial end substantially but reduces trial signups. Without one, the subscription lands in incomplete or is cancelled at trial end depending on your trial_settings.end_behavior.

Dunning. Roughly a tenth of recurring card charges fail on any given attempt, largely from expired cards and issuer declines. Stripe's Smart Retries schedule up to four attempts over about two weeks, timed to when a charge is most likely to succeed. Configure what happens at the end of that window in Billing settings: cancel, mark unpaid, or leave past_due. Then decide what your app does during those two weeks. Cutting access on the first failure churns customers whose card simply expired. A grace period with an in-app banner linking to the Customer Portal recovers a meaningful share of them.

The store billing boundary, which is not negotiable

This is where Stripe integrations get apps rejected. Apple's and Google's rules turn on what the digital good is, not on which payment processor is technically better.

What is being soldStripe allowed in-app?
Access to app features, content, premium tiersNo. Must use StoreKit or Google Play Billing
Physical goods and deliveryYes
Real-world services (a haircut, a repair, an in-person class)Yes
Business-facing SaaS bought by the business, not the app userGenerally yes, but check the current guidelines

For a booking app where customers pay a groomer or a contractor, Stripe is the correct and permitted rail. For an app selling its own premium tier to consumers, the platform's in-app purchase system is required, and Stripe belongs on your web signup flow instead. Note the recent shift: following the 2025 US injunction in the Epic case, Apple now permits US storefront apps to link out to external purchase pages without taking a commission, but this is jurisdiction-specific and still moving. Do not build a business model on a rule that changed last year and may change again.

Where a hybrid is unavoidable, keep entitlement in one place. Whether the receipt came from StoreKit, Play Billing or a Stripe webhook, normalize it into the same user record so the app has exactly one question to ask: does this user have access right now.

Test the paths that break in production

Card 4242 4242 4242 4242 proves nothing. The tests worth writing use 4000 0000 0000 0341, which attaches successfully then fails on charge, and 4000 0025 0000 3155, which forces 3D Secure authentication. Use the Stripe CLI to replay events out of order, fire the same event twice, and confirm your handler is genuinely idempotent. Then use test clocks to advance a subscription through a trial end, a renewal, a failed payment and the full retry schedule in a few minutes rather than a month. Most subscription bugs only appear on day 31, and test clocks are the only practical way to see them before your customers do.

CrateShip Studios
White-label Flutter apps, delivered in 30-60 days, from $2,500 - full source code included.
Get started