- An entitlement is the durable access state your app grants after a purchase —
pro,team,plus. A product is the SKU a customer buys on a rail. An offering is the set of products on a paywall. You check the entitlement; you never check the raw SKU. - Model entitlements, not products. It decouples what a customer bought from what they can do, so pricing, promos, packaging, and payment rails can all change without touching your access logic.
- Many products map to one entitlement. Monthly, annual, promo, lifetime, and the same tier on Apple, Google Play, and Stripe can all grant
pro. The mapping lives in one place — adding a plan or a rail is config, not code. - Grant to the customer, not the platform. Resolve every purchase to one identity so the entitlement travels across iOS, Android, and web — one payment relationship, not three.
- Keep entitlements real-time. Drive them from verified server events (App Store Server Notifications, Google Play RTDN, Stripe webhooks) and reconcile against the store so a refund, cancellation, or failed renewal updates access within moments — never a cache that goes stale.
- Debug "should have Pro but doesn't" by walking the chain — app check → server grant → product mapping → verified event → one identity. The break is almost always a missed webhook, an unmapped SKU, or two identities never merged.
Definitions used in this guide
The specific thing a customer purchases on a rail, such as ios_monthly_pro. Owned by Apple, Google Play, or Stripe. A billing identifier.
The access your app grants because of a purchase, such as pro. Owned by your app and access policy. An access promise, not a billing identifier.
The set of products presented together on a paywall — the packaging and pricing a customer chooses from. A merchandising concept you change often.
The billing system that processes a payment: the App Store, Google Play, or Stripe. Revenue is counted by rail; each rail reports purchases differently.
A subscription whose state is confirmed against the store's own server signals rather than trusted from a client — the basis of a non-stale entitlement.
Resolving purchases, events, and access from every rail to one customer identity, so one entitlement is true across every platform the customer uses.
The window after a renewal payment fails but before the subscription cancels, during which access usually stays on while the customer fixes payment.
The lifecycle a grant moves through: active, in grace, expired, refunded, revoked. Access logic reads the state, not just a boolean.
What is an entitlement? The short answer
An entitlement is the access state your app grants a customer after they buy a qualifying product — a durable flag such as pro, team, or plus that your code checks whenever it needs to decide what a person is allowed to do. When a paywall converts, the customer buys a product on a payment rail; in response, your app grants an entitlement. From that moment, your feature gates, your paywall logic, your premium API — all of it — should ask one question and only one question: is this customer entitled to pro? They should never ask which SKU the customer bought, on which store, at which price.
That single indirection — check the entitlement, not the product — is the most important design decision in a paid app, and almost every access bug that ships to production is a violation of it. The reason it matters is that products change all the time and access promises do not. You will run price experiments, add an annual plan, launch a promo, add a lifetime tier, expand from iOS to Android to web, and swap or add payment rails. Each of those is a change to your catalogue. None of them is a change to what pro means. If your code checks entitlements, the catalogue can churn underneath you and your application logic never moves. If your code checks product IDs, every one of those changes leaks into your codebase as a new conditional, a new special case, and eventually a new access bug.
The rest of this guide is the complete version of that answer. We cover the three concepts every paid app has to separate — product, entitlement, and offering — and exactly how they differ. We explain why you model entitlements rather than products, and what it costs you when you don't. We show how to design features that map cleanly to entitlements, how to map Apple, Google Play, and Stripe SKUs to one entitlement, how one customer holds the same access across iOS, Android, and web, and how to keep entitlements real-time so they never go stale after a refund or a cancellation. We close with the full lifecycle — grace periods, expiry, refunds — and a step-by-step method for debugging the single most common support ticket in subscription software: "this user should have Pro but doesn't." We built products and entitlements into our own platform and run it across every rail, so the examples here are first-hand, not theoretical.
Product vs entitlement vs offering: the three concepts
Paid apps get into trouble because three different ideas are casually called "the plan," and they are not the same idea. Untangling them is the whole foundation. A product is what a customer buys. An entitlement is what your app grants. An offering is how you present the products to choose from. Get these three straight and most of the hard problems in subscription infrastructure become bookkeeping; blur them and every problem becomes a debugging session.
A product — often called a SKU — is a concrete purchasable item defined inside a payment rail. On the App Store it is an in-app purchase or subscription product with an identifier like ios_monthly_pro. On Google Play it is a product with a base plan. On Stripe it is a price attached to a product. The rail owns it, the rail bills it, and the rail's identifier is a billing fact, not an access fact. You will have many products: one per billing period, per rail, per promo, per region.
An entitlement is a stable access key your app owns: pro, team, plus. It answers the question "what can this customer do," and its name should describe the access promise, never the billing mechanism. One entitlement is normally granted by many products — every product that unlocks premium maps to pro — which is precisely why it is worth having as its own concept. The entitlement is the thing your code checks; the products are the things your customers buy.
An offering is a merchandising concept: the specific set of products you present together on a given paywall, in a given order, at a given price, to a given audience. "The summer offering" might present a monthly and an annual product with the annual discounted; "the win-back offering" might present a single discounted annual. Offerings are what you change when you run a pricing experiment. Crucially, changing an offering changes what customers see and buy; it must never change what the resulting purchase unlocks. Two offerings can sell different products that grant the same entitlement.
Here is the whole model in one table. It is the single most useful thing to pin above your desk while you design a paid app.
| Concept | What it is | Example | Who owns it | How often it changes |
|---|---|---|---|---|
| Product (SKU) | A purchasable item on a rail | ios_monthly_pro | Apple / Google Play / Stripe | Often — every plan, promo, region |
| Entitlement | The access a purchase grants | pro | Your app and access policy | Rarely — it is a stable promise |
| Offering | The set of products on a paywall | "Annual-first offering" | Your growth / paywall team | Constantly — pricing experiments |
| The check in code | The question your app asks | isEntitled("pro") | Your client and server logic | Almost never |
The column that matters most is the last one: how often each thing changes. Products and offerings change constantly; entitlements and the checks against them barely change at all. When your application logic is coupled to the fast-changing columns, it churns; when it is coupled to the slow-changing column — the entitlement — it stays still. That single observation is the reason the entitlement concept exists, and everything else in this guide follows from it. If you want the shortest possible version of this distinction, we wrote a focused companion piece on products vs entitlements — the simplest way to manage paid app access, and a plain-English primer on what an entitlement is in app subscriptions. If your mental model comes from RevenueCat's vocabulary, the mapping is nearly one-to-one; we walk through it in RevenueCat products, entitlements, and offerings explained.
Why you model entitlements, not products
The core reason is one sentence: modelling entitlements decouples what a customer bought from what they can do. Everything else is a consequence of that decoupling. When "bought" and "can do" are the same field in your code — when a feature gate reads a store product ID directly — the two are welded together, and you cannot change one without risking the other. When they are separate, with an entitlement in between, each can change on its own schedule. Your billing catalogue is free to evolve; your access surface stays stable.
Consider the alternative concretely, because the pain is specific. Suppose your paywall checks the raw SKU. Today your premium tier is ios_monthly_pro, so your code reads if (product == "ios_monthly_pro") { unlock() }. It works. Then reality arrives, in this order:
- You add an annual plan,
ios_yearly_pro. Now the condition is wrong for every annual subscriber, so you add an OR. Two SKUs in the conditional. - You run a launch promo,
ios_promo_pro_2026. Another OR. Three. - You ship Android. Every SKU has a Google Play twin. Six.
- You add web checkout through Stripe with monthly and annual prices. Eight.
- Marketing asks for a regional price in a new market, which the stores model as new product IDs. Ten, then twelve.
- You add a
teamtier — and now the same tangle exists a second time, in every place your app gates a feature.
Within two quarters, a one-line check has become a growing list of magic strings duplicated across your codebase, and every pricing decision is now an engineering task with a QA pass and a real chance of a lockout. This is not a hypothetical failure mode; it is the default trajectory of any paid app that skips the entitlement layer. The store catalogue only ever grows, and each addition multiplies against every gate.
With entitlements, the same six changes are absorbed without touching a single feature gate. Every one of those products maps to pro in one place — the mapping table — and your code still reads isEntitled("pro"). Adding the annual plan is a config change: attach ios_yearly_pro to pro. Shipping Android is a config change. Adding Stripe is a config change. The application logic — the part that is expensive to change and dangerous to get wrong — never moves. That is the whole return on the abstraction: pricing and packaging become config; access stays code you wrote once.
The three things entitlements protect you from
It helps to name the specific failure modes the entitlement layer prevents, because each maps to a real category of production incident.
- Naming drift. Without a stable access key, teams invent parallel names for the same access —
monthly,premium,paid,subscriber— and they slowly diverge across client, server, docs, and support. One entitlement key ends the drift. - Access drift. When gates check SKUs, a new plan that someone forgot to add to one gate silently fails to unlock a feature. The customer paid and can't use what they bought. Central mapping means a product either grants
proeverywhere or nowhere. - Reporting drift. If access and billing are the same field, your revenue view and your access view are the same view — and they can't disagree, which sounds good until you need them to. You want to ask "how many customers have
pro" independently of "how much revenue did the annual SKU book." Separating the concepts lets each answer be correct.
An entitlement layer is, in the end, the same discipline as any good interface: a stable contract on one side, free-to-change implementation on the other. Your customers and your billing rails are the volatile implementation. pro is the contract.
Designing features that map cleanly to entitlements
The entitlement layer only pays off if your features are designed to sit on top of it cleanly. The rule is simple to state: every gated capability should ask for an entitlement, and every entitlement should correspond to a coherent bundle of capabilities a customer would recognise as "a plan." When those two things line up, gating is trivial and the paywall writes itself. When they don't, you get entitlements that are really just renamed SKUs, or features that check three unrelated flags to decide one thing.
Start from the customer's mental model, not the billing model. A customer thinks in tiers — "free," "pro," "team" — and in capabilities — "I can export," "I can invite teammates," "I get the higher limits." Design your entitlements to match those tiers, then decide, for each capability, which entitlement unlocks it. The cleanest apps have a small number of entitlements (often two or three) and gate every premium capability against one of them. Resist the urge to mint an entitlement per feature; that just moves the SKU sprawl into your access layer.
Feature flags, tiers, and entitlements are not the same thing
A common muddle is to conflate three kinds of flag that happen to look alike in code:
- An entitlement answers "has this customer paid for this access?" It is driven by billing state.
- A feature flag answers "is this feature turned on for this customer?" It is driven by rollout, experiment, or ops, and has nothing to do with payment.
- A tier is a marketing bundle — a name for a group of entitlements sold together.
Keep them separate. A premium feature that is also being gradually rolled out is gated by both an entitlement (did they pay) and a flag (is it live for them yet). Collapsing the two means you can't dark-launch a paid feature, and you can't give an entitlement to someone without also exposing an unfinished feature. In practice, the gate reads: show the feature if isEntitled("pro") and the rollout flag is on. Two independent questions, one decision.
Model capabilities, then compose entitlements from them
For apps with more than a couple of tiers, it helps to define capabilities as the atomic unit — export, team_seats, advanced_analytics, higher_rate_limit — and then define each entitlement as the set of capabilities it grants. pro grants export and advanced analytics; team grants everything in pro plus team seats. Your code then checks capabilities at the point of use (can("export")), and the entitlement-to-capability mapping lives in one place. This gives you the flexibility to repackage — move a capability from team down to pro — as a config change, without hunting for every gate. It is the same decoupling principle applied one level deeper, and it is worth it once you have real tiering. We go through the concrete patterns, including how to avoid over-fragmenting, in how to design paid features that map cleanly to entitlements.
Name entitlements for the promise, not the price
One discipline prevents most future pain: name the entitlement after what the customer can do, not after how they paid. Good names — pro, team, plus, lifetime_pro — describe access. Bad names — monthly, yearly, ios_pro, stripe_premium — smuggle billing cadence, platform, or rail into the access key, which reintroduces exactly the coupling you were trying to remove. If you ever find yourself with an entitlement called annual, you have created a synonym for a SKU and gained nothing. The test: could you change your entire pricing model tomorrow and keep the entitlement name honest? If yes, it is named correctly.
Mapping Stripe, Apple, and Play SKUs to one entitlement
This is where the abstraction meets the rails, and it is more mechanical than it sounds. You create the entitlement first as a stable key, then attach every qualifying product from every rail to it. The mapping is a lookup: given a rail and a product identifier, return an entitlement key. Keep that lookup in exactly one place — a table, a config file, a mapping service — and the messy per-rail differences stay contained there instead of leaking into your app.
Each rail names and structures its products differently, and knowing the shape of each is the practical half of the job:
- App Store (Apple). Products are in-app purchases or auto-renewable subscriptions, each with a product ID you define in App Store Connect (illustrative:
ios_monthly_pro). Subscriptions belong to a subscription group; only one subscription in a group is active at a time, which Apple uses to model upgrades and downgrades within a tier. - Google Play. A subscription is a product that contains one or more base plans (monthly, annual), and base plans can carry offers (a free trial, an intro price). So a single Play product ID can map to several billing shapes, and your mapping usually keys on the product plus base plan (illustrative:
play_pro/pro-monthly). - Stripe. A product has one or more prices (monthly, annual, per-region, per-currency). The purchase records a price ID and a subscription; your mapping usually keys on the Stripe price or product (illustrative:
price_pro_monthly).
The mapping table itself is deliberately boring. Every row says "this product, on this rail, grants this entitlement." Adding a plan is a new row; adding a rail is a batch of new rows; nothing else in your app changes. Here is an illustrative version for a single pro entitlement sold across three rails at two cadences.
| Rail | Product / SKU | Billing shape | Grants entitlement |
|---|---|---|---|
| App Store | ios_monthly_pro | Auto-renewable, monthly | pro |
| App Store | ios_yearly_pro | Auto-renewable, annual | pro |
| Google Play | play_pro / pro-monthly | Base plan, monthly | pro |
| Google Play | play_pro / pro-annual | Base plan, annual | pro |
| Stripe | price_pro_monthly | Recurring, monthly | pro |
| Stripe | price_pro_yearly | Recurring, annual | pro |
| App Store | ios_promo_pro_2026 | Intro-priced promo | pro |
| Stripe | price_pro_lifetime | One-off (non-recurring) | lifetime_pro |
Two details in that table are worth calling out. First, the promo and the standard plans all grant the same pro — a promo is a pricing decision, not an access decision, so it maps to the same entitlement. Second, the lifetime purchase is a one-off and grants a different, non-expiring entitlement, which keeps recurring and one-off access cleanly distinguished in your model. That recurring-versus-one-off split matters downstream: recurring entitlements have renewal, grace, and expiry states; one-off entitlements do not.
The reason to centralise the mapping is not tidiness for its own sake. It is that the mapping is the one place that has to know about every rail's quirks — Apple's subscription groups, Play's base plans and offers, Stripe's prices and currencies — and containing that knowledge in one lookup keeps it out of a hundred call sites. When Apple adds a new product for a new region, you add a row. When you connect Stripe web checkout to your existing mobile entitlement, you add rows. Neither is a code change to your gates. We cover the mobile-plus-Stripe case in depth — including the identity questions it raises — in how to map Stripe products to mobile app entitlements and, for the specific web-buys-then-opens-the-app flow, in how to connect Stripe web subscriptions to iOS app access.
One payment relationship across iOS, Android, and web
Mapping SKUs to an entitlement solves half the cross-platform problem. The other half is identity: the entitlement has to be granted to the customer, not to the store account or the device that happened to make the purchase. This is the difference between "this Apple account has an active App Store subscription" and "this person has pro." The first is a platform fact; the second is what your app actually needs to know, and it should be true no matter which platform the customer is standing on.
The failure this prevents is one every multi-platform app hits. A customer subscribes on the web through Stripe, then downloads the iOS app and signs in. If your iOS app asks Apple "does this device have an active subscription," the answer is no — the purchase was on Stripe — and the customer who just paid gets a paywall. They are, understandably, furious. The fix is not more StoreKit code; it is to stop asking the platform and start asking your own entitlement system: does this signed-in identity have pro? Because the Stripe purchase granted pro to that identity, the answer is yes, on every platform, immediately.
Getting there requires resolving purchases from all rails to one customer record. A purchase arrives with a rail-native identifier — an Apple original transaction ID, a Google purchase token, a Stripe customer ID — and your job is to attach each of those to the same person, keyed by your own stable user ID (typically your auth identity). Once every rail's purchases resolve to one customer, entitlements are computed per customer and are automatically cross-platform. The customer has one payment relationship with you, expressed across however many rails they have used, and one set of entitlements that follows them everywhere.
This is also what makes account sharing, family plans, and "I bought on my other phone" tractable rather than terrifying: they are all questions about which purchases resolve to which identity, answered in one place, instead of platform-specific special cases scattered through your clients. The full pattern — including how to handle a customer who buys on two rails, and how to reconcile a purchase made before the customer signed in — is in how to use one payment relationship across iOS, Android, and web.
Why "grant to the platform" always breaks eventually
Even single-platform apps drift into this problem. A customer reinstalls, switches from an old iPhone to a new one, or restores purchases into a fresh account, and suddenly the same human is two records with one subscription between them. If entitlements are granted to the platform receipt rather than the identity, the customer's access depends on which record they are currently signed into. Granting to a stable identity — and treating the platform purchase as evidence attached to that identity — makes reinstalls and device changes non-events. The entitlement is a property of the person, restored the moment they authenticate, regardless of hardware.
Real-time entitlements that never go stale
An entitlement is only useful if it is true right now. A real-time entitlement reflects the customer's actual billing state within moments of it changing, because it is driven by verified server events and reconciled against the store — not cached from a check the client made at some point in the past. A stale entitlement is the opposite: it still says active after a refund, a cancellation, or a failed renewal, because nothing came along to update it. Stale entitlements are expensive in both directions — they give away paid access after a refund, and they lock out paying customers after a renewal your system didn't see.
The mechanism for staying real-time is to treat the stores' own server signals as the source of truth and to update the entitlement the moment they arrive:
- App Store Server Notifications (v2) — Apple posts subscription lifecycle events (renewals, cancellations, refunds, billing retry, grace period entry) to your server as they happen.
- Google Play Real-time Developer Notifications (RTDN) — Play publishes subscription state changes to a Pub/Sub topic your server subscribes to.
- Stripe webhooks — Stripe posts
invoice.paid,customer.subscription.updated,customer.subscription.deleted, refund, and dispute events to your endpoint.
Each event maps to an entitlement state transition: a renewal keeps pro active and extends its expiry, a refund revokes it, a cancellation schedules it to expire at period end, a failed renewal moves it into a grace or retry state. Because these are verified server-to-server signals, the resulting entitlement is a verified subscription — you are not trusting a value the client asserted, you are recording what the billing rail told your server actually happened.
Why webhooks alone are not enough: reconciliation
Here is the trap that produces most stale entitlements even in teams that wired up webhooks correctly: notifications get missed. Endpoints have outages, deploys drop in-flight requests, a Pub/Sub subscription lapses, a signature check rejects a valid event during a key rotation. Miss the one renewal notification and the entitlement expires while the customer is happily paying; miss the one refund notification and you hand out pro for free. An event-only system is correct exactly as long as it never misses an event, which is never.
The fix is reconciliation: periodically re-check the true state against the store's query API and correct any drift, so a missed event is repaired on the next pass instead of persisting forever. Real-time notifications keep entitlements fresh in the common case; reconciliation is the backstop that keeps them correct in the failure case. A system that does both — react to every event and reconcile against the source — is what "never goes stale" actually requires. This is the same discipline that makes a revenue view trustworthy; we unpack it in what an app revenue source of truth is.
This is exactly what Crossdeck's verified entitlements do: every rail's notifications feed one entitlement state, reconciled against the store, resolved to one customer identity via the cross-match. The entitlement is real-time because it moves the moment the rail does, and it is correct because reconciliation catches whatever the notification stream dropped.
Grace periods, expiry, and refunds: the entitlement lifecycle
A boolean is not enough to model access honestly. A real entitlement moves through a lifecycle of states, and your access logic should read the state, not just ask "true or false." The states that matter for a recurring entitlement are active, in grace, expired, refunded, and revoked. Each corresponds to a real billing situation and each implies a different access decision and a different customer message.
| State | What happened | Should access be on? | What to tell the customer |
|---|---|---|---|
active | Paid and current | Yes | Nothing — it just works |
in grace | Renewal failed, retrying | Usually yes | "Update your payment method to keep Pro" |
expired | Lapsed or cancelled at period end | No | "Your Pro access has ended — resubscribe" |
refunded | Purchase refunded by the rail | No — revoke | Usually nothing; access ends quietly |
revoked | Chargeback, fraud, or manual removal | No — revoke | Depends on policy |
Grace periods: keep access on while payment recovers
When a renewal charge fails — an expired card is the usual culprit — the stores don't cancel immediately. They enter a billing retry and, if you have enabled it, a grace period: a window (commonly up to 16 days, depending on the rail and billing cycle) during which the subscription is still considered active while the rail retries the charge. The right entitlement behaviour during grace is almost always to keep access on and flag the state so you can nudge the customer to fix payment. Cutting pro the instant a charge fails punishes a customer for an expired card and turns a recoverable billing hiccup into a cancellation. Keeping access on through the grace window is one of the highest-leverage retention decisions in a subscription app, and it costs you almost nothing because most of these charges eventually succeed. The mechanics per rail — how long the window is, which notifications signal entry and exit — are covered in billing retry and grace period explained for iOS subscriptions.
Expiry: end access cleanly at the right moment
An entitlement expires when a subscription lapses without renewing, or when a customer cancels and the paid period runs out. The important nuance is when: a cancellation is not an immediate expiry. A customer who cancels an annual plan in month two has usually paid through month twelve and should keep pro until then. So a cancellation event should set the entitlement to expire at period end, not revoke it on the spot. Expiring early is a support ticket and a refund request; expiring on time is invisible. Your entitlement therefore needs an expiry timestamp, not just an on/off flag, so it can be active-until-a-date.
Refunds and chargebacks: revoke, promptly
A refund is the state most often missed, and the one that quietly leaks the most money. When Apple, Google, or Stripe reports that a purchase was refunded or charged back, the access that purchase granted should be revoked — otherwise you are giving away paid features to someone who got their money back, and your revenue reporting overstates what you kept. Refunds are easy to miss because they arrive asynchronously, often days or weeks after checkout, as a server notification with no user action attached. There is no moment in your app's flow to catch them; they only exist as an event on your server. This is the clearest argument for driving entitlements from server notifications and reconciliation rather than from client-side purchase checks: the refund never touches the client, so a client-driven entitlement can never see it. Understanding why customers refund is a separate but related discipline — we cover it in how to track refunds and understand why users ask for them.
One-off purchases have a simpler lifecycle
Not everything recurs. A lifetime unlock, a consumable, or a one-time pro upgrade is a one-off purchase, and its entitlement lifecycle is shorter: it is granted on purchase and, for non-consumables, stays granted unless refunded. There is no renewal, no grace, no period-end expiry. Keeping recurring and one-off entitlements distinct in your model — as the earlier mapping table did with lifetime_pro — means you don't accidentally apply renewal logic to something that was bought once, or expire something the customer owns forever.
Cross-platform access and the cross-match
Put the previous three sections together — mapping, identity, real-time state — and you get the property that is genuinely hard to build and genuinely valuable to have: one entitlement that is true across every platform and every rail, continuously. A customer buys pro monthly on iOS, upgrades to annual on the web a year later, and installs the Android app on a new tablet. Across all of that, there is one pro, in one state, resolved to one person, and every surface reads the same answer. That is cross-platform access done right, and it is the thing most home-grown subscription code never quite reaches, because each of the three ingredients is easy to get slightly wrong.
We call the resolution step the cross-match: joining purchases, subscription events, and product usage from every rail to one customer identity, so that entitlements — and revenue, and behaviour, and errors — are all computed against the same person. The cross-match is what lets you answer a question no single rail can: does this human have pro, given everything they have ever bought from us, anywhere? Apple can only tell you about App Store purchases; Stripe only about Stripe; Play only about Play. The customer does not experience their relationship with you as three separate ledgers, and your entitlement system shouldn't either.
The practical payoff of cross-platform entitlements shows up in a dozen everyday moments that otherwise become bugs: restoring purchases after a reinstall, a customer contacting support from a device they didn't buy on, a family member using an account, a web-first customer opening the mobile app, an upgrade that spans rails. With a cross-matched entitlement, all of these are the same operation — look up the identity, read its entitlement state — instead of a stack of platform-specific edge cases. When teams describe "our subscription logic is a nightmare of special cases," this missing join is almost always the root cause. If you also need to compare what each platform earns while keeping one access model, the reporting side is covered across the revenue cluster on the blog.
Debugging "user should have Pro but doesn't"
This is the single most common support ticket in subscription software, and it is entirely diagnosable if you walk the chain in order. The customer paid, so an entitlement should be active, but the app is showing a paywall. Somewhere between the payment and the access check, one link is broken. The method is to walk that chain from the symptom (no access) back to the payment, checking each link, because the break is almost always in a small number of predictable places. Do not guess; walk it.
Here is the chain, from the app backwards to the money, with the question to ask at each link.
- Is the app checking the entitlement, or a stale cache? The client may be reading an old cached entitlement from before the purchase, or checking the wrong key. Force a refresh from the server and confirm the app asks for the right entitlement name. Surprisingly often, the entitlement is active server-side and the client just never refetched.
- Is the entitlement granted on the server? Look at the customer's entitlement state in your source of truth. If it is
active, the bug is client-side (link 1). If it is missing,expired, orin grace, keep walking — the problem is upstream. - Is the product that should grant it actually mapped to the entitlement? A new plan, promo, or region SKU that never got added to the mapping table will process the payment but grant nothing. Check that the exact product the customer bought maps to the entitlement. This is the classic cause after any pricing change.
- Did the purchase or renewal event arrive and verify? Look for the App Store notification, Play RTDN, or Stripe webhook for this transaction. If it never arrived (missed webhook, endpoint outage, key rotation), the grant never fired. If it arrived but failed verification, it was rejected. This is where reconciliation earns its keep — a reconcile pass would have repaired it.
- Does everything resolve to one identity? If the purchase resolved to a different customer record than the one the customer is signed into — two accounts, a purchase made before sign-in, a reinstall into a fresh identity — then the entitlement exists, just on the "wrong" person. The fix is merging the identities, not re-granting.
In our experience the breaks cluster hard at three links: link 3 (an unmapped SKU after a catalogue change), link 4 (a missed or unverified event), and link 5 (two identities that were never joined). If you instrument nothing else, instrument those three, because they cover the large majority of "should have Pro but doesn't" tickets. The reason a cross-matched, reconciled entitlement system makes this class of bug rare is that it hardens exactly those three links: central mapping removes link 3, reconciliation removes link 4, and the cross-match removes link 5. We wrote a dedicated, screenshot-level walkthrough of this exact investigation in how to debug a subscription user who should have Pro access.
Where to check entitlements: client vs server
A recurring question once the model is in place: where should the entitlement check actually run? The answer is both places, for two different jobs, and confusing the two jobs is a security bug. The client checks entitlements to decide what to show; the server checks entitlements to decide what to allow.
The client-side check is a convenience and a UX decision. It shows or hides the premium button, renders the paywall or the feature, greys out the locked control. It is fast, it works offline against the last known state, and it is inherently untrusted — anyone can modify a client, so a client-side entitlement check is a suggestion, not an authorisation. That is fine for UI: the worst case of a spoofed client-side check is that someone sees a button they shouldn't.
The server-side check is the real gate. Anything that costs you money or delivers paid value on the server — a premium API endpoint, an export job, a compute-heavy operation, a download of gated content — must verify the entitlement on the server, against your source of truth, before doing the work. Never authorise a server action based on a flag the client sent; the client can send any flag it likes. The rule of thumb: if bypassing the check would cost you money or leak paid value, it belongs on the server. If bypassing it would only show someone a button, the client is fine.
This split is also why the entitlement has to live server-side as the source of truth, with the client holding a cached copy for UI. The server computes the real entitlement state from verified events and reconciliation; the client fetches it to render, and refetches when it changes. Both check the same entitlement key — isEntitled("pro") — but only one of them is trusted to authorise. Getting this right is what separates a paid app that can be trivially unlocked with a proxy from one that can't.
Entitlement anti-patterns to avoid
Most entitlement problems are one of a handful of recognisable anti-patterns. Naming them makes them easy to catch in review, before they become production access bugs.
- Checking raw product IDs in app code. The original sin. Every gate that reads a SKU is a place a pricing change can break access. Route every check through an entitlement key.
- One entitlement per SKU. Minting
monthly_proandyearly_proas separate entitlements just renames the products and keeps all the coupling. Monthly and annual of the same tier are one entitlement. - Naming entitlements after billing.
annual,ios_pro, andstripe_premiumsmuggle cadence, platform, and rail into the access key. Name for the promise:pro. - Client-only entitlements. Trusting a client-computed flag to authorise server work. Anything that costs money is gated server-side.
- Event-only, no reconciliation. Relying on webhooks with no backstop. One missed notification and the entitlement is wrong until a human notices. Reconcile.
- Granting to the platform, not the person. Tying access to an Apple account or a device receipt instead of your own identity. Breaks on reinstall, device change, and cross-platform.
- Boolean-only state. Modelling access as on/off with no grace, expiry, or refunded states. You cannot keep access on through a billing retry or revoke it on a refund if all you have is a boolean.
- Forgetting to map new SKUs. Adding a plan or region and processing the payment, but never attaching the new product to the entitlement. The customer pays and unlocks nothing. Central mapping plus a "new SKU grants an entitlement" checklist prevents it.
Every one of these traces back to the same root: collapsing the billing identifier and the access identifier into one thing. Keep them separate, with a stable entitlement in between, and the whole category disappears.
The entitlement design checklist
A practical checklist to hold a paid-access design against. If you can tick every line, your entitlements are modelled correctly and most of the common access bugs cannot occur.
- [ ] Every feature gate checks an entitlement key (
isEntitled("pro")), never a raw store SKU. - [ ] Entitlements are named for the access promise (
pro,team), not for cadence, platform, or rail. - [ ] Many products map to one entitlement, in a single central mapping; adding a plan or rail is a config change.
- [ ] Recurring and one-off purchases grant distinct entitlements with distinct lifecycles.
- [ ] Entitlements are granted to a stable customer identity, not a device or store account, so they survive reinstalls and cross platforms.
- [ ] Entitlement state is driven by verified server events — App Store notifications, Play RTDN, Stripe webhooks — not client-side purchase checks.
- [ ] A reconciliation pass re-checks state against each rail so a missed event is repaired, not permanent.
- [ ] The entitlement models lifecycle states (active, in grace, expired, refunded, revoked) with an expiry timestamp — not a bare boolean.
- [ ] Access stays on through billing retry / grace, and cancellations expire at period end, not immediately.
- [ ] Refunds and chargebacks revoke the entitlement they paid for.
- [ ] Anything that costs money or delivers paid value is gated by a server-side entitlement check; the client check is UI only.
- [ ] One customer's purchases across every rail resolve to one entitlement via a cross-match.
This checklist is the compressed form of everything above. It is also, not coincidentally, a description of what Crossdeck's products and entitlements do out of the box — because we built it against exactly these failure modes, running it across every rail on our own platform.
Frequently asked questions
What is an entitlement?
An entitlement is the access state your app grants a customer after they buy a qualifying product — a durable flag like pro, team, or plus that your code checks to decide what a person can do. Products are what a customer buys on a payment rail; the entitlement is what your app unlocks in response. You check the entitlement, never the raw store SKU.
What is the difference between a product and an entitlement?
A product (or SKU) is a specific thing a customer purchases on Apple, Google Play, or Stripe — for example ios_monthly_pro. An entitlement is the access your app grants because of that purchase — for example pro. Many products can map to one entitlement: a monthly plan, an annual plan, a promo, and a web checkout can all grant pro. The product is a billing identifier; the entitlement is an access promise your app owns.
What is the difference between an entitlement and an offering?
An offering is the set of products you present on a paywall — the packaging and pricing a customer chooses from. An entitlement is what a purchase from that offering unlocks. Offerings are a merchandising concept you change often for pricing experiments; entitlements are an access concept you keep stable. Changing an offering should never change what pro means in your code.
Why model entitlements instead of products?
Because it decouples what a customer bought from what they can do. Store catalogues, prices, promos, and payment rails change constantly; the access promise behind pro should not. If your feature gates check raw product IDs, every pricing change leaks into your app code and every new rail multiplies your conditionals. If they check one entitlement key, packaging can change freely and your access logic stays still.
Can one entitlement have many products attached?
Yes, and it should. A single entitlement such as pro is normally granted by many products: monthly and annual plans, an introductory promo, a lifetime purchase, and the same tier bought on Apple, Google Play, and Stripe. Mapping many products to one entitlement is the entire reason the concept exists — it is how one access promise survives a changing catalogue and multiple payment rails.
How do you map Stripe, App Store, and Google Play SKUs to one entitlement?
Create the entitlement first as a stable key — pro. Then attach every qualifying product from every rail to it: the App Store product ID, the Google Play product and base plan, and the Stripe price or product. The mapping is a lookup from rail plus product ID to an entitlement key, kept in one place so adding a new plan or a new rail is a config change, not a code change. Your app then only ever asks whether pro is active.
What does it mean for an entitlement to be real-time and not stale?
A real-time entitlement reflects the customer's true billing state within moments of it changing, because it is driven by verified server events — App Store Server Notifications, Google Play Real-time Developer Notifications, and Stripe webhooks — and reconciled against the store rather than cached from an old client check. A stale entitlement is one that still says active after a refund, cancellation, or failed renewal, because nothing updated it. Stale entitlements cause both revenue leakage and wrongful lockouts.
How can one customer have the same access across iOS, Android, and web?
By resolving every purchase to the same person and granting the entitlement to that identity rather than to a device or a store account. When a customer buys on the web and opens the iOS app, the app should ask whether this identity has pro, not whether this Apple account has an active App Store subscription. One payment relationship across rails means the entitlement travels with the customer, not with the platform they happened to pay on.
How do I debug a user who should have Pro access but doesn't?
Work backwards along the chain from access to payment: confirm the app is checking the entitlement and not a stale cache, confirm the entitlement is granted on the server, confirm the product that should grant it is mapped to the entitlement, confirm the purchase or renewal event actually arrived and was verified, and confirm all of it resolves to the same customer identity rather than a second account. The break is almost always a missed webhook, an unmapped product, or two identities that were never merged.
What happens to an entitlement during a grace period?
During a billing grace period — after a renewal payment fails but before the subscription is cancelled — access should usually stay on. The entitlement is active but flagged in a recovery state so you can prompt the customer to fix payment without locking them out mid-cycle. Cutting access the instant a charge fails punishes customers for an expired card and drives cancellations; keeping it on through the grace window recovers most of them.
What should happen to an entitlement on a refund?
A refund should revoke the entitlement it paid for, promptly. When Apple, Google, or Stripe reports a refund or chargeback, the access that purchase granted should end, otherwise you are giving away paid features and your revenue reporting overstates what you actually kept. Refund revocation is one of the most commonly missed events because it arrives as an asynchronous notification long after checkout, so it has to be handled server-side, not at purchase time.
Should entitlement checks happen on the client or the server?
Both, for different jobs. The client checks the entitlement to decide what UI to show — which is convenience and can be spoofed. The server checks the entitlement to authorise anything that must not be reachable without paying — premium API endpoints, exports, compute. Anything that costs you money or exposes paid value on the server should be gated by a server-side entitlement check, not by trusting a flag the client sent.
Should an entitlement name match the store SKU?
No. The entitlement name should describe the access promise (pro, team, plus), not the billing identifier (ios_monthly_pro, stripe_yearly_pro). If the entitlement key mirrors the SKU, you have simply renamed the product and kept all the coupling — a pricing or packaging change will still force a code change. Name entitlements for what the customer can do, not for how or where they paid.
Does Crossdeck work across iOS, Android, and web?
Yes. Crossdeck is designed around one customer timeline across Apple, Google Play, Stripe, and web or mobile product events, so the same entitlement and revenue model can travel across surfaces.
Give every customer one verified entitlement
Crossdeck turns purchases from Apple, Google Play, and Stripe into one verified entitlement per customer — real-time, reconciled against each rail so it never goes stale, and cross-matched to one identity across iOS, Android, and web. Model access once; let packaging, pricing, and rails change underneath it. Start free and map your first entitlement.