Back to documentation

Last updated: August 10, 2026

How subscription access works in your app

A subscription access check is a server-side decision about whether a user has paid-for access to a specific product right now. It is a single answer the rest of your app can act on, derived from the subscription state your backend already tracks, not from a live call to a payment provider on the request path.

This doc covers what a subscription access check is, why billing state is not the same as access state, and the subscription states an access check has to resolve before answering. The mechanics apply whether you build access tracking yourself or use a service that does it for you.

What a subscription access check is

A subscription access check answers one question: is this user subscribed to this product right now? The answer is per user, per product, and time-bounded. A user can have access to one of your products and not another, and the same answer can flip from yes to no when its access period ends or the provider reports a new state.

A useful access check returns more than a boolean. It returns:

  • the product the access applies to
  • whether access is granted
  • why access is granted or denied
  • when access ends, if known

Apps use the reason to vary UI (a trial banner vs. an active-subscriber view) and the end-at timestamp to drive renewal prompts and grace-period messaging.

Billing state vs. access state

Billing state is what the payment provider tracks: the customer record, the latest invoice, the payment method, the next renewal attempt. Access state is what your app tracks: is this user entitled to use this product right now?

These are not the same. Several common cases prove it:

  • A canceled subscription whose period has not ended. Billing is canceled. Access is granted until the period the user already paid for ends.
  • A trial that has not converted. Billing has not charged anything. Access is granted.
  • A past-due renewal in retry. Billing is failing. Depending on the provider, access may be granted, in a grace window, or already expired.
  • A refund processed after a renewal. Billing reflects the refund. Access should reflect it too, but the provider does not retroactively flip the access decision for you.

Billing state is the financial truth. Access state is the application truth derived from it.

Subscription states that affect access

Stripe, Apple, and Google use different state names. The access decisions still fall into a small set of common cases:

StateHas access?Notes
Active subscriptionYesStandard paid subscription.
TrialYesTrial period has not ended. The next renewal will attempt the first charge.
Canceled with time remainingYesThe subscription was cancelled, but access continues until the paid period ends.
Payment failed during graceYesAccess continues while a failed payment is being resolved. This includes Apple and Google grace periods and Stripe past_due subscriptions.
Payment failed after graceNoThe provider is trying to recover payment, but access has stopped. This includes Apple billing retry and Google account hold.
Paused subscriptionNoThe provider reports that the subscription itself is paused.
Expired subscriptionNoPeriod ended without renewal.

The canceling case is one apps often get wrong. A user who cancels on day 5 of a 30-day period still has 25 days of access. Cutting them off at cancel-time is a refund risk and a support hit.

Why runtime provider checks break

It is tempting to call the payment provider whenever access needs to be checked like when a user logs in, opens a paid feature, or hits an authenticated endpoint. That approach fails in several ways.

  • Latency. Calling Stripe, Apple, or Google adds tens to hundreds of milliseconds to every request that needs a subscription check. On the hot path of every authenticated request, that latency is not acceptable.
  • Provider outages. If a subscription access check depends on a live provider response, a provider incident becomes a user-facing outage of your app.
  • Different APIs. Stripe returns a Subscription object. Apple returns a transaction history via the App Store Server API. Google returns a SubscriptionPurchaseV2 from purchases.subscriptionsv2.get. Each has a different shape and a different mapping to "is this user subscribed right now."

Over time, the runtime-check approach leads to fragile authorization logic and subtle bugs where users lose access incorrectly or keep it longer than they should.

Treating access as derived state

A reliable access check is derived, not queried. The pattern has three pieces.

  1. Ingestion. Each provider sends a webhook whenever subscription state changes. Stripe sends a customer.subscription.updated event, Apple sends an App Store Server Notification V2, and Google Play sends a Real-time Developer Notification (RTDN). Your backend verifies the signature, deduplicates by event ID, and writes the new state to a normalized store.
  2. Normalization. Each provider's event names map to shared concepts such as active subscriptions, trials, cancellations, payment recovery, pauses, and expirations.
  3. Access derivation. When your app calls the access check, the answer is computed from the normalized store. No provider call. No retry logic on the hot path. The check is safe to call once per authenticated request because it reads from your own store.

For the events the ingestion step has to handle, see how to handle Stripe subscription webhooks, how to handle Apple subscription webhooks, and how to handle Google Play subscription webhooks and RTDN.

Access is per product, not per user

For apps that sell a single product, a boolean answer is enough. For apps that sell more than one, an access check has to answer per product, not per user. A SaaS app with a Basic plan and a Pro plan needs to know about both independently for a single user.

A natural shape for that is a list of entitlements, one per product, each carrying has-access, reason, and access-end-at. Tier checks stay independent that way, and apps with a single product can read the only entry and ignore the rest.

Common access-check mistakes

  • Trusting client-side receipts. A mobile client can lie about its subscription status, and the user can revoke the receipt after the fact. Server-side derived state is the only trustworthy signal.
  • Polling the provider on every request. Latency, rate limits, and provider outages all break this pattern.
  • Treating cancellation as immediate. The user paid through the end of the current period, so cutting them off early is a problem.
  • Ignoring grace and paused states. A paused subscription is not canceled. A past-due subscription may still grant access during the provider's grace window.
  • Conflating user identity with subscription identity. A user can have multiple subscriptions across products (and across providers). The mapping from a provider-side subscription to a user in your app is what the metadata fields exist to carry: subscription metadata in Stripe, appAccountToken in Apple, and obfuscatedExternalAccountId in Google. Without that mapping, valid webhooks become orphaned records.
  • Caching the answer without an invalidation path. Caching access decisions is fine. Caching them in a way no event can invalidate is not. Webhook updates have to be able to bust the cache.

Frequently asked questions

SubTru is the subscription backend behind an access check. Point Stripe, Apple, and Google webhooks at SubTru. SubTru verifies signatures, deduplicates by event ID, retries failed deliveries, and normalizes subscription state across providers. Your app calls one endpoint (GET /v1/projects/{projectId}/users/{external_user_id}/access) and receives a list of per-product entitlements, each with hasAccess, an accessReason such as active_subscription or payment_failed_past_grace_period, and an accessEndsAt timestamp. The endpoint shape is the same whether you use one provider or all three. Your provider accounts stay yours. SubTru sits next to them, not in front of them.

Sources